From af950d36d99a5eb9e4e81498aa73245b2bc37839 Mon Sep 17 00:00:00 2001 From: Thales <> Date: Sat, 29 Aug 2026 15:00:16 +0100 Subject: [PATCH 1/3] Zoom the waveform lanes with the wheel, without changing what they look like Scroll over the lanes to zoom between 1x and 5x, anchored on the pointer so the moment under the cursor stays under it. Shift-scroll pans. A track always opens fitted. The interesting constraint was the art. Each lane is an SVG whose viewBox width is the bar count, drawn with preserveAspectRatio="none", so widening it without redrawing multiplies every bar's width by the zoom factor: the same picture with fatter strokes, which reads as a zoom for about a second and then reads as a bug. The bars are redrawn at the new count instead. Measured at 1600px: 182 bars over 910px at 1x, 910 bars over 4550px at 5x, and 5.000 pixels per bar at both. Same 3px bar, same 2px gap, same rounding, five times the detail. Nothing needed a new width formula for that. .waves-column already sits inside .wave-canvas, which is width: calc(100% * var(--zoom)), so overviewBarCount() reads the zoomed width on its own. Redrawing after the reflow is the whole fix. 5x is where the source runs out rather than a round number. peaks.json carries 1500 points per stem, and past roughly 5x a typical panel asks for more bars than there are points behind them, at which point the extra bars repeat their neighbours: detail that is not there. Where decoded buffers are in RAM the peaks are recomputed at the bar count instead of a fixed 1500, so the wide-panel case gets real samples rather than stair-steps. 1x is a floor for the opposite reason: zoomed out further there is nothing left to show. The ruler subdivides as you zoom, on a 1/2/5/10/15/30/60s ladder with a 110px minimum between labels. Ticks are positioned as a percentage of the timeline, so without this the same handful of labels simply spread across five screen widths and the ruler got less useful the further in you went. 1x keeps exactly the step it always had, and the footer strip, which always shows the whole track, is untouched. The loop tools are 1x only. A drag while zoomed would define a region whose ends are off screen, and the overlay marks a percentage of a timeline the user can see a fifth of. The button, the exact-loop fields and the drag are all inert above 1x and say why in a tooltip. The bounds are never discarded: zooming back restores the loop exactly as it was, including whether it was running. The zoom is published through state.js rather than imported, because player.js already imports transport.js and the redraw would have closed the cycle. Same shape as setFooterWaveDrawFn next to it. Nine browser tests. The two that carry the feature were checked against a broken build first: disabling the redraw fails the bar-width test, removing the gate fails the loop test. The streaming path is not covered. It draws WaveSurfer canvases instead of the SVG and gets its own zoom call, whose bars are configured in pixels so a re-render keeps them the same width by construction. The e2e fixture produces no canvases at all in that mode, which predates this and is worth its own look. Closes #492 --- static/css/daw.css | 13 +++ static/js/i18n.js | 9 ++ static/js/player.js | 33 ++++++- static/js/state.js | 14 +++ static/js/transport.js | 158 +++++++++++++++++++++++++++++-- tests/e2e/zoom.spec.mjs | 200 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 415 insertions(+), 12 deletions(-) create mode 100644 tests/e2e/zoom.spec.mjs diff --git a/static/css/daw.css b/static/css/daw.css index fcb64625..e338699e 100644 --- a/static/css/daw.css +++ b/static/css/daw.css @@ -2053,6 +2053,19 @@ input, textarea { font-family: inherit; } .loop-region.hidden { display: none !important; } +/* ── Waveform zoom ── */ +/* Zoomed in, the loop tools are inert (see loopToolsAvailable in transport.js): + a drag would define a region whose ends are off screen. Say so rather than + letting the controls look live and do nothing. The crosshair is the "you can + drag a loop here" affordance, so it goes too. */ +.loop-btn.zoom-locked, +#t-loop.zoom-locked { + opacity: 0.4; + cursor: not-allowed; +} +.waves-column.zoom-locked { cursor: default; } +.daw .lanes-ruler-time.zoom-locked { cursor: default; } + /* Wave loading overlay */ .wave-loading-overlay { position: absolute; inset: 0; z-index: 10; diff --git a/static/js/i18n.js b/static/js/i18n.js index 884e6749..de73655b 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -399,6 +399,7 @@ const en = { "position.group": "Position", "position.loopTitle": "Loop the selected position (L)", + "position.loopZoomLocked": "Looping is available at 1x zoom. Reset the zoom to use it.", "position.loopLabel": "Loop position", "position.loopExactTitle": "Exact loop start / end (mm:ss.mmm or seconds)", "position.loopStartAria": "Loop start", @@ -950,6 +951,7 @@ const pl = { "position.group": "Pozycja", "position.loopTitle": "Zapętl zaznaczoną pozycję (L)", + "position.loopZoomLocked": "Zapętlanie działa przy powiększeniu 1x. Zresetuj powiększenie, aby go użyć.", "position.loopLabel": "Pętla pozycji", "position.loopExactTitle": "Dokładny początek / koniec pętli (mm:ss.mmm lub sekundy)", "position.loopStartAria": "Początek pętli", @@ -1492,6 +1494,7 @@ const ja = { "position.group": "位置", "position.loopTitle": "選択した位置をループ (L)", + "position.loopZoomLocked": "ループはズーム 1x でのみ使えます。ズームをリセットしてください。", "position.loopLabel": "位置をループ", "position.loopExactTitle": "正確なループ開始/終了 (mm:ss.mmm または秒)", "position.loopStartAria": "ループ開始", @@ -2010,6 +2013,7 @@ const zhHans = { "position.group": "位置", "position.loopTitle": "循环所选位置 (L)", + "position.loopZoomLocked": "循环仅在 1x 缩放下可用。请重置缩放后使用。", "position.loopLabel": "循环位置", "position.loopExactTitle": "精确循环起止点 (mm:ss.mmm 或秒)", "position.loopStartAria": "循环起点", @@ -2529,6 +2533,7 @@ const de = { "position.group": "Position", "position.loopTitle": "Ausgewählte Position loopen (L)", + "position.loopZoomLocked": "Loopen ist bei 1x-Zoom verfügbar. Zoom zurücksetzen, um es zu nutzen.", "position.loopLabel": "Loop-Position", "position.loopExactTitle": "Genauer Loop-Start/-Ende (mm:ss.mmm oder Sekunden)", "position.loopStartAria": "Loop-Start", @@ -3058,6 +3063,7 @@ const pt = { "position.group": "Posição", "position.loopTitle": "Repetir a posição selecionada (L)", + "position.loopZoomLocked": "O loop fica disponível no zoom 1x. Redefina o zoom para usá-lo.", "position.loopLabel": "Posição de loop", "position.loopExactTitle": "Início/fim exato do loop (mm:ss.mmm ou segundos)", "position.loopStartAria": "Início do loop", @@ -3588,6 +3594,7 @@ const id = { "position.group": "Posisi", "position.loopTitle": "Loop posisi yang dipilih (L)", + "position.loopZoomLocked": "Loop tersedia pada zoom 1x. Atur ulang zoom untuk memakainya.", "position.loopLabel": "Posisi loop", "position.loopExactTitle": "Awal/akhir loop yang tepat (mm:ss.mmm atau detik)", "position.loopStartAria": "Awal loop", @@ -4107,6 +4114,7 @@ const fr = { "position.group": "Position", "position.loopTitle": "Boucler la position sélectionnée (L)", + "position.loopZoomLocked": "La boucle est disponible au zoom 1x. Réinitialisez le zoom pour l'utiliser.", "position.loopLabel": "Boucler la position", "position.loopExactTitle": "Début / fin exacts de la boucle (mm:ss.mmm ou secondes)", "position.loopStartAria": "Début de la boucle", @@ -4737,6 +4745,7 @@ const es = { "position.group": "Posición", "position.loopTitle": "Repetir en loop la posición seleccionada (L)", + "position.loopZoomLocked": "El bucle está disponible con zoom 1x. Restablece el zoom para usarlo.", "position.loopLabel": "Loop de la posición", "position.loopExactTitle": "Inicio / fin exacto del loop (mm:ss.mmm o segundos)", "position.loopStartAria": "Inicio del loop", diff --git a/static/js/player.js b/static/js/player.js index 174bd204..237071b1 100644 --- a/static/js/player.js +++ b/static/js/player.js @@ -22,7 +22,7 @@ import { setLoopEnabled, setLoopStart, setLoopEnd, setMasterVolume, waveScroll, selectedStems, footerTitle, footerMeta, footerThumb, - setFooterWaveDrawFn, + setFooterWaveDrawFn, setOverviewRerenderFn, metronome, setMetronome, metronomeEnabled, metronomeVolume, metronomeBeatsPerBar, exportClickEl, exportClickWrap, exportCountInEl, exportCountInWrap, setMetronomeHasBars, @@ -42,7 +42,7 @@ import { } from "./mixer.js"; import { buildRuler, updatePlayheadMarker, updateLoopRegionVisual, - applyWaveZoom, buildPresenceRuler, buildFooterWaveTicks, updateFooterTimes, + applyWaveZoom, resetWaveZoom, buildPresenceRuler, buildFooterWaveTicks, updateFooterTimes, updatePresencePlayhead, resetSpeed, resetPitch, updatePitchAvailability, updateMetronomeAvailability, applyMetronomeAccent, } from "./transport.js"; @@ -433,7 +433,23 @@ function overviewLaneNames(stems) { return present.has("original") ? ["original", ...order] : order; } +// What the overview bars were last drawn from, so a zoom change can redraw them +// at the new resolution without reloading the track. Zoom widens .waves-column, +// overviewBarCount() reads that width, and the bars come back the same 3px wide +// with more of them -- redrawing is what keeps the art identical, where +// stretching the same SVG would smear it. +let _overviewSource = null; + +function rerenderOverviewWaveforms() { + if (!_overviewSource) return; + const { kind, stems, data } = _overviewSource; + if (kind === "peaks") renderAllOverviewWaveformsFromPeaks(stems, data); + else renderAllOverviewWaveforms(stems, data); +} +setOverviewRerenderFn(rerenderOverviewWaveforms); + function renderAllOverviewWaveformsFromPeaks(stems, peaksData) { + _overviewSource = { kind: "peaks", stems, data: peaksData }; const laneNames = overviewLaneNames(stems); // Only the extracted/selected stems (plus original) get a waveform, even if // peaks.json carries data for stems the user didn't keep (Demucs separates @@ -463,13 +479,20 @@ function renderAllOverviewWaveformsFromPeaks(stems, peaksData) { // matching what a DAW shows. Per-stem normalization made every lane // fill its row regardless of how loud the stem actually was. function renderAllOverviewWaveforms(stems, decodedMap) { + // Decoded buffers beat peaks.json: bufferMinMaxPeaks can be asked for as many + // points as the zoom needs, where peaks.json is fixed at 1500. + _overviewSource = { kind: "decoded", stems, data: decodedMap }; const laneNames = overviewLaneNames(stems); const peaksByStem = new Map(); let globalMax = 0; for (const name of laneNames) { const buf = decodedMap.get(name); if (!isAudioBufferLike(buf)) continue; - const peaks = bufferMinMaxPeaks(buf, OVERVIEW_WAVE_POINTS); + // Enough points for the bars actually being drawn. At 1x that is well under + // OVERVIEW_WAVE_POINTS, but a wide panel at 5x asks for more bars than 1500, + // and short of that every extra bar would repeat its neighbour's sample and + // the zoom would show stair-steps instead of detail. + const peaks = bufferMinMaxPeaks(buf, Math.max(OVERVIEW_WAVE_POINTS, overviewBarCount())); peaksByStem.set(name, peaks); for (const [mn, mx] of peaks) { if (mx > globalMax) globalMax = mx; @@ -1009,6 +1032,10 @@ export function wireUpAudio(jobId, stems, duration, thumbnail, mixUrl = null, ti }, 60000); setCurrentJobId(jobId); setTotalDuration(duration || 0); + // A new track starts fitted. Carrying the previous track's zoom over would + // open it scrolled into the middle of a song the user has not seen yet, with + // the loop tools silently unavailable. + resetWaveZoom(); refreshMixerVisuals(); const mixReady = loadMixIntoState(jobId, stems.map((s) => s.name)) .then(() => { if (currentJobId === jobId) refreshMixerVisuals(); }) diff --git a/static/js/state.js b/static/js/state.js index 0f778813..2f3636c7 100644 --- a/static/js/state.js +++ b/static/js/state.js @@ -195,6 +195,13 @@ export function setAudioContext(v) { audioContext = v; } export function setMasterVolume(v) { masterVolume = v; } export let playbackSpeed = 1.0; export function setPlaybackSpeed(v) { playbackSpeed = v; } +// Horizontal waveform zoom. 1 is the whole track fitted to the panel and is +// also the floor: there is nothing to see below it, the track is already +// entirely on screen. Shared state because three modules read it -- transport.js +// drives it, player.js redraws the bars at the new resolution, and the loop +// tools are only available at 1. +export let waveZoom = 1; +export function setWaveZoom(v) { waveZoom = v; } export function setVuRafId(v) { vuRafId = v; } export function setMasterBusGain(v) { masterBusGain = v; } export function setMasterLimiter(v) { masterLimiter = v; } @@ -203,6 +210,13 @@ export function setMasterLimiter(v) { masterLimiter = v; } export let footerWaveDrawFn = null; export function setFooterWaveDrawFn(fn) { footerWaveDrawFn = fn; } +// Redraws the overview bars at the current zoom. Registered by player.js, which +// owns the renderer, and called by transport.js, which owns the zoom. Passed as +// a callback rather than imported so the two modules do not form a cycle: +// player.js already imports transport.js. +export let overviewRerenderFn = null; +export function setOverviewRerenderFn(fn) { overviewRerenderFn = fn; } + // Click track. `metronome` is the scheduler bound to the current engine (null // when the job has no beat grid or the streaming path is in use); the enabled // flag and volume survive track switches so the user's choice sticks. diff --git a/static/js/transport.js b/static/js/transport.js index a1f83ea8..90e3a1db 100644 --- a/static/js/transport.js +++ b/static/js/transport.js @@ -19,6 +19,7 @@ import { setMetronomeHasBars, setMetronomeEnabled, setMetronomeVolume, setMetronomeBeatsPerBar, setLoopEnabled, setLoopStart, setLoopEnd, setMasterVolume, setPlaybackSpeed, + waveZoom, setWaveZoom, overviewRerenderFn, } from "./state.js"; import { applyMix, nudgeAllLanePitches, resetAllLanePitches } from "./mixer.js"; import { isDownbeatIndex, getBeats as getGridBeats, getBars as getGridBars } from "./beatgrid.js"; @@ -26,6 +27,16 @@ import { computeCountIn } from "./metronome.js"; import { t } from "./i18n.js"; const MIN_LOOP_SEC = 0.2; +// Zoom range. 1 is the whole track fitted to the panel; there is nothing below +// it to show, so it is the floor rather than a soft default. 5 is the ceiling +// because peaks.json carries 1500 points per stem: past roughly 5x a typical +// panel asks for more bars than there are samples behind them, and the extra +// bars repeat their neighbours instead of revealing anything. +const WAVE_ZOOM_MIN = 1; +const WAVE_ZOOM_MAX = 5; +// One wheel notch. Multiplicative, so a notch covers the same proportion of the +// range at 1x as at 4x; linear steps feel fast at the bottom and stuck at the top. +const WAVE_ZOOM_STEP = 1.18; // Below this visible width the waveform stops compressing to fit and instead // keeps a minimum size, overflowing horizontally so .wave-scroll can scroll. const WAVE_MIN_WIDTH = 720; @@ -72,8 +83,26 @@ function setPlayheadTime(sec) { // Spacing of the timeline's labelled ticks. Shared by the ruler above the // lanes and the one on the footer waveform: the two strips are the same width // and start at the same x, so a time has to land at the same place in both. -function tickStep(durationSec) { - return durationSec < 90 ? 15 : durationSec < 300 ? 30 : 60; +// Label spacing the ruler will not go below, comfortably wider than a "10:00" +// label so neighbours never crowd each other. +const MIN_TICK_PX = 110; +const TICK_LADDER = [1, 2, 5, 10, 15, 30, 60, 120, 300]; + +// `contentWidthPx` is the width the ticks will actually occupy. Omitted (the +// footer strip, which always shows the whole track) the step is the plain +// duration-based one, which is also what 1x has always used. +function tickStep(durationSec, contentWidthPx = 0) { + const base = durationSec < 90 ? 15 : durationSec < 300 ? 30 : 60; + // Zoom is the only thing that subdivides it. Spreading the same handful of + // ticks across five screen widths would make the ruler less useful the + // further in you went, which is backwards. + if (waveZoom <= 1 || !contentWidthPx || !durationSec) return base; + const pxPerSec = contentWidthPx / durationSec; + for (const step of TICK_LADDER) { + if (step > base) break; + if (step * pxPerSec >= MIN_TICK_PX) return step; + } + return base; } export function buildRuler(durationSec) { @@ -87,7 +116,9 @@ export function buildRuler(durationSec) { rulerTime.appendChild(marker); if (!durationSec || durationSec <= 0) return; - const step = tickStep(durationSec); + // The ruler is width: calc(100% * var(--zoom)), so its own box already is the + // zoomed width; no need to recompute it here. + const step = tickStep(durationSec, rulerTime.getBoundingClientRect().width); for (let t = 0; t <= durationSec; t += step) { const leftPct = (t / durationSec) * 100; const tick = document.createElement("div"); @@ -210,7 +241,7 @@ export function updateLoopRegionVisual() { // programmatic change (drag, toggle). Never overwrite a field the user is // actively editing, and disable both when no track is loaded. function syncLoopInputs() { - const enabled = totalDuration > 0; + const enabled = totalDuration > 0 && loopToolsAvailable(); for (const [input, value] of [ [loopStartInput, loopStart], [loopEndInput, loopEnd], @@ -391,6 +422,7 @@ export function stopTransport() { } export function toggleLoop() { + if (!loopToolsAvailable()) return; setLoopEnabled(!loopEnabled); loopBtn.classList.toggle("active", loopEnabled); updateLoopRegionVisual(); @@ -407,6 +439,9 @@ function wireLoopDrag() { const startDrag = (e, surface) => { if (e.button !== 0 || e.target.closest(".loop-region")) return; + // Zoomed in, a drag would define a region whose ends are off screen and + // whose overlay is positioned against a timeline the user cannot see. + if (!loopToolsAvailable()) return; const t = timeFromClientX(e.clientX); if (t === null) return; dragging = true; @@ -494,8 +529,10 @@ export function applyWaveZoom() { if (multitrack && totalDuration > 0 && waveScroll) { const baseWidth = waveScroll.clientWidth; if (baseWidth > 0) { - // Fit to the visible width, but never compress below WAVE_MIN_WIDTH. - const contentWidth = Math.max(baseWidth, WAVE_MIN_WIDTH); + // Two separate reasons the content can be wider than the viewport, and + // they multiply rather than compete: the user's zoom, and the floor that + // stops the whole track compressing into a sliver on a narrow window. + const contentWidth = Math.max(baseWidth * waveZoom, WAVE_MIN_WIDTH); const zoom = contentWidth / baseWidth; // Widen the container via --zoom FIRST. Then, after the browser has // reflowed it, zoom WaveSurfer to fit the container's *actual* width. @@ -508,12 +545,99 @@ export function applyWaveZoom() { if (!multitrack || totalDuration <= 0) return; const w = multitrackContainer?.clientWidth || contentWidth; try { multitrack.zoom(w / totalDuration); } catch { /* ignore -- pre-canplay */ } + // Redraw the SVG bars against the width the reflow actually produced. + // The bars are 1 viewBox unit each, so leaving the old count in place + // would stretch every bar by the zoom factor: same waveform, fatter + // strokes. Redrawing keeps them 3px wide and spends the extra width on + // detail instead, which is the whole point of zooming in. + overviewRerenderFn?.(); syncRulerScroll(); }); } } } +// The loop tools belong to the whole-track view. Zoomed in, a drag selects a +// region the user cannot see the ends of, and the region overlay is positioned +// as a percentage of a timeline that is now mostly off screen. So they are +// available at 1x and inert everywhere else. +// +// The bounds themselves are never discarded. Zooming out restores the loop +// exactly as it was, including whether it was running. +let _loopArmedBeforeZoom = false; + +export function loopToolsAvailable() { + return waveZoom === WAVE_ZOOM_MIN; +} + +function syncLoopAvailability() { + const available = loopToolsAvailable(); + if (!available && loopEnabled) { + _loopArmedBeforeZoom = true; + setLoopEnabled(false); + loopBtn.classList.remove("active"); + updateLoopRegionVisual(); + } else if (available && _loopArmedBeforeZoom) { + _loopArmedBeforeZoom = false; + setLoopEnabled(true); + loopBtn.classList.add("active"); + updateLoopRegionVisual(); + } + if (loopBtn) { + loopBtn.disabled = !available; + loopBtn.classList.toggle("zoom-locked", !available); + loopBtn.title = available ? t("position.loopTitle") : t("position.loopZoomLocked"); + } + for (const input of [loopStartInput, loopEndInput]) { + if (!input) continue; + input.disabled = !available || totalDuration <= 0; + } + document.querySelector(".waves-column")?.classList.toggle("zoom-locked", !available); + rulerTime?.classList.toggle("zoom-locked", !available); +} + +/** + * Set the zoom, keeping the time under `anchorClientX` where it is. + * + * Without the anchor the view jumps to wherever scrollLeft happened to be, and + * zooming toward a specific bar becomes a game of chase-the-scrollbar. + */ +export function setWaveZoomLevel(next, anchorClientX = null) { + const clamped = Math.min(WAVE_ZOOM_MAX, Math.max(WAVE_ZOOM_MIN, next)); + if (Math.abs(clamped - waveZoom) < 1e-4) return false; + const previous = waveZoom; + // Which content pixel the anchor is on, before anything moves. + const rect = waveScroll?.getBoundingClientRect(); + const offsetX = anchorClientX !== null && rect + ? Math.min(rect.width, Math.max(0, anchorClientX - rect.left)) + : (waveScroll ? waveScroll.clientWidth / 2 : 0); + const contentX = (waveScroll?.scrollLeft ?? 0) + offsetX; + + setWaveZoom(clamped); + applyWaveZoom(); + syncLoopAvailability(); + // Everything positioned against the timeline is laid out again at the new + // width: the ruler because its ticks are now the wrong distance apart for the + // detail on screen, the playhead and the loop region because buildRuler + // rebuilds the elements they live in. + buildRuler(totalDuration); + updatePlayheadMarker((audioEngine ?? multitrack)?.getCurrentTime?.() ?? 0); + updateLoopRegionVisual(); + + if (waveScroll) { + // The same content pixel after the widening, minus where it sits in the + // viewport, is the scroll offset that leaves it under the pointer. + const target = contentX * (clamped / previous) - offsetX; + waveScroll.scrollLeft = Math.max(0, target); + syncRulerScroll(); + } + return true; +} + +export function resetWaveZoom() { + return setWaveZoomLevel(WAVE_ZOOM_MIN); +} + function wireZoomButtons() { if (waveScroll) { let rafId = null; @@ -526,15 +650,31 @@ function wireZoomButtons() { } if (waveScroll) { waveScroll.addEventListener("wheel", (e) => { - if (waveScroll.scrollWidth <= waveScroll.clientWidth) return; - if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) { + if (totalDuration <= 0) return; + // Shift is the pan gesture, and a trackpad's horizontal axis reports as + // deltaX with no modifier. Both mean "move along the track", so neither + // should change the zoom. + if (e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY)) { + if (waveScroll.scrollWidth <= waveScroll.clientWidth) return; e.preventDefault(); - waveScroll.scrollLeft += e.deltaY; + waveScroll.scrollLeft += (e.shiftKey ? e.deltaY : e.deltaX) || e.deltaY; + syncRulerScroll(); + return; } + if (!e.deltaY) return; + // deltaMode 1 is lines and 2 is pages; both deliver far smaller numbers + // than pixels, so normalise to notches rather than scaling by deltaY. + const notches = Math.max(1, Math.min(3, Math.round(Math.abs(e.deltaY) / 100) || 1)); + const factor = WAVE_ZOOM_STEP ** (e.deltaY < 0 ? notches : -notches); + const changed = setWaveZoomLevel(waveZoom * factor, e.clientX); + // Only swallow the event when it did something. At either end of the + // range the page should still get its scroll rather than feel dead. + if (changed) e.preventDefault(); }, { passive: false }); waveScroll.addEventListener("scroll", syncRulerScroll, { passive: true }); } applyWaveZoom(); + syncLoopAvailability(); } // Keep the mixer column and the waveform area scrolled in lockstep so stem diff --git a/tests/e2e/zoom.spec.mjs b/tests/e2e/zoom.spec.mjs new file mode 100644 index 00000000..b6672f9b --- /dev/null +++ b/tests/e2e/zoom.spec.mjs @@ -0,0 +1,200 @@ +// Scroll-wheel zoom on the mixer waveforms. +// +// The thing worth guarding here is not that a number changes: it is that the +// bars keep their width. The overview is an SVG whose viewBox width is the bar +// COUNT, so widening it without redrawing stretches every bar by the zoom +// factor. That looks like a zoom at a glance and is really just a fatter +// version of the same picture, which is exactly the failure this feature exists +// to avoid. Every assertion about "art" below is measuring that. + +import { test, expect } from "@playwright/test"; +import { openStudio, JOB_ID } from "./helpers.mjs"; + +const wheel = (page, dy, x = 900, y = 400) => + page.mouse.move(x, y).then(() => page.mouse.wheel(0, dy)); + +async function zoomState(page) { + return page.evaluate(() => { + const scroller = document.querySelector(".wave-scroll"); + const canvas = document.querySelector(".wave-canvas"); + const svg = document.querySelector(".stem-waveform-row[data-stem] .stem-waveform-svg"); + const rects = svg ? svg.querySelectorAll("rect") : []; + const box = svg ? svg.getBoundingClientRect() : null; + return { + zoomVar: parseFloat( + getComputedStyle(document.getElementById("lanes")).getPropertyValue("--zoom"), + ) || 1, + viewport: Math.round(scroller.clientWidth), + content: Math.round(canvas.getBoundingClientRect().width), + scrollLeft: Math.round(scroller.scrollLeft), + bars: rects.length, + // One bar occupies one viewBox unit, so its on-screen width is the + // rendered svg width divided by the bar count. That is the number that + // must not move when the zoom does. + barSlotPx: box && rects.length ? +(box.width / rects.length).toFixed(3) : null, + ticks: document.querySelectorAll("#ruler-time .tick").length, + tickLabels: [...document.querySelectorAll("#ruler-time .tick-label")].slice(0, 3).map((e) => e.textContent), + loopDisabled: document.getElementById("t-loop").disabled, + loopStartDisabled: document.getElementById("t-loop-start").disabled, + }; + }); +} + +// Deliberately wide: the bar-count maths only has room to prove itself when the +// panel can hold a few hundred bars. +test.use({ viewport: { width: 1600, height: 900 } }); + +test.describe("waveform zoom", () => { + test("a track opens fitted, with the loop tools available", async ({ page }) => { + await openStudio(page, { tauri: true }); + const s = await zoomState(page); + expect(s.zoomVar).toBeCloseTo(1, 2); + expect(s.content).toBe(s.viewport); + expect(s.loopDisabled).toBe(false); + expect(s.loopStartDisabled).toBe(false); + }); + + test("scrolling up zooms in and scrolling down comes back", async ({ page }) => { + await openStudio(page, { tauri: true }); + const start = await zoomState(page); + + await wheel(page, -300); + const zoomed = await zoomState(page); + expect(zoomed.zoomVar).toBeGreaterThan(start.zoomVar); + expect(zoomed.content).toBeGreaterThan(start.content); + + await wheel(page, 300); + const back = await zoomState(page); + expect(back.zoomVar).toBeCloseTo(start.zoomVar, 2); + expect(back.content).toBe(start.content); + }); + + test("the bars keep their width; zooming buys detail, not fatter bars", async ({ page }) => { + await openStudio(page, { tauri: true }); + const base = await zoomState(page); + expect(base.bars).toBeGreaterThan(50); + + for (let i = 0; i < 12; i++) await wheel(page, -240); + const zoomed = await zoomState(page); + + // The proof: same pixels per bar, more bars, wider content. + expect(zoomed.barSlotPx).toBeCloseTo(base.barSlotPx, 1); + expect(zoomed.bars).toBeGreaterThan(base.bars * 2); + expect(zoomed.content).toBeGreaterThan(base.content * 2); + // Bar count tracks content width, which is what "same art" means here. + expect(zoomed.bars / base.bars).toBeCloseTo(zoomed.content / base.content, 1); + }); + + test("zoom stops at 5x and never goes below the fitted view", async ({ page }) => { + await openStudio(page, { tauri: true }); + const fitted = await zoomState(page); + + for (let i = 0; i < 40; i++) await wheel(page, -240); + const maxed = await zoomState(page); + expect(maxed.content / fitted.content).toBeCloseTo(5, 1); + + for (let i = 0; i < 60; i++) await wheel(page, 240); + const floored = await zoomState(page); + expect(floored.content).toBe(fitted.content); + expect(floored.scrollLeft).toBe(0); + }); + + test("the pointer stays over the same moment in the track", async ({ page }) => { + await openStudio(page, { tauri: true }); + const rect = await page.locator(".wave-scroll").boundingBox(); + const anchorX = rect.x + rect.width * 0.75; + + const before = await zoomState(page); + const timeUnderPointer = (before.scrollLeft + (anchorX - rect.x)) / before.content; + + for (let i = 0; i < 8; i++) await wheel(page, -240, anchorX, rect.y + 60); + const after = await zoomState(page); + const nowUnderPointer = (after.scrollLeft + (anchorX - rect.x)) / after.content; + + // Within half a percent of the track: the anchor holds, so zooming toward a + // bar does not turn into chasing the scrollbar. + expect(Math.abs(nowUnderPointer - timeUnderPointer)).toBeLessThan(0.005); + }); + + test("the loop tools are inert while zoomed and come back at 1x", async ({ page }) => { + await openStudio(page, { tauri: true }); + + // The ruler, not the lane body: the fixture keeps its loading overlay over + // the lanes, which would swallow the drag and pass this test for the wrong + // reason. The ruler is the canonical loop surface anyway. + const ruler = await page.locator("#ruler-time").boundingBox(); + const dragRuler = async (fromFrac, toFrac) => { + const y = ruler.y + ruler.height / 2; + await page.mouse.move(ruler.x + ruler.width * fromFrac, y); + await page.mouse.down(); + await page.mouse.move(ruler.x + ruler.width * toFrac, y, { steps: 8 }); + await page.mouse.up(); + }; + + await dragRuler(0.2, 0.6); + await expect(page.locator("#t-loop")).toHaveClass(/active/); + const armed = await page.locator("#loop-region").getAttribute("style"); + + await wheel(page, -300); + const zoomed = await zoomState(page); + expect(zoomed.loopDisabled).toBe(true); + expect(zoomed.loopStartDisabled).toBe(true); + await expect(page.locator("#t-loop")).not.toHaveClass(/active/); + await expect(page.locator("#loop-region")).toHaveClass(/hidden/); + + // A drag while zoomed must not define a new region. + await dragRuler(0.05, 0.35); + await expect(page.locator("#loop-region")).toHaveClass(/hidden/); + + // Back at 1x the original loop returns, unchanged. + for (let i = 0; i < 30; i++) await wheel(page, 300); + const back = await zoomState(page); + expect(back.content).toBe(back.viewport); + expect(back.loopDisabled).toBe(false); + await expect(page.locator("#t-loop")).toHaveClass(/active/); + expect(await page.locator("#loop-region").getAttribute("style")).toBe(armed); + }); + + test("the ruler gets finer as you zoom, and the footer strip does not", async ({ page }) => { + await openStudio(page, { tauri: true }); + const base = await zoomState(page); + const footerBefore = await page.locator("#footer-wave-ticks .tick").count(); + + for (let i = 0; i < 40; i++) await wheel(page, -240); + const zoomed = await zoomState(page); + + // Same ticks spread over five screen widths would be a worse ruler the + // further in you went. More of them, at a finer step. + expect(zoomed.ticks).toBeGreaterThan(base.ticks); + expect(zoomed.tickLabels[1]).not.toBe(base.tickLabels[1]); + + // The footer strip always shows the whole track, so its ticks must not move. + expect(await page.locator("#footer-wave-ticks .tick").count()).toBe(footerBefore); + }); + + test("shift-scroll pans instead of zooming", async ({ page }) => { + await openStudio(page, { tauri: true }); + for (let i = 0; i < 10; i++) await wheel(page, -240); + const zoomed = await zoomState(page); + + await page.keyboard.down("Shift"); + await wheel(page, 200); + await page.keyboard.up("Shift"); + const panned = await zoomState(page); + + expect(panned.content).toBe(zoomed.content); + expect(panned.scrollLeft).not.toBe(zoomed.scrollLeft); + }); + + test("opening another track returns to the fitted view", async ({ page }) => { + await openStudio(page, { tauri: true }); + for (let i = 0; i < 10; i++) await wheel(page, -240); + expect((await zoomState(page)).zoomVar).toBeGreaterThan(1); + + await page.locator(`.cat-item[data-id="${JOB_ID}"]`).first().click(); + await page.waitForTimeout(1500); + const reopened = await zoomState(page); + expect(reopened.zoomVar).toBeCloseTo(1, 2); + expect(reopened.loopDisabled).toBe(false); + }); +}); From f96fd0fc7be721520c3c55a766f9e94b709b21d6 Mon Sep 17 00:00:00 2001 From: Thales <> Date: Sat, 29 Aug 2026 15:28:00 +0100 Subject: [PATCH 2/3] Let the loop and the zoom coexist The first cut made the loop tools 1x-only, on the reasoning that a drag while zoomed would define a region whose ends are off screen. That reasoning was backwards: marking a loop precisely is one of the main reasons to zoom in at all, and the ends being off screen is the user's business, not the app's. Loops can now be created, edited and toggled at any zoom, and zoom never touches one that already exists. Nothing had to be built for the drag to be correct while zoomed. timeFromClientX measures against the ruler's own bounding box, and the ruler is width: calc(100% * var(--zoom)) translated by scrollLeft, so its box is the whole zoomed timeline wherever it happens to be scrolled to. The region overlay is positioned in percentages inside .waves-column, which carries the same zoomed width, so it lands on the same span at every zoom by construction. Both are now asserted rather than assumed: a drag across the middle of a 5x view is checked against the times the pointer was actually over. What went is the apparatus that was suppressing all this. syncLoopAvailability, loopToolsAvailable, the remembered pre-zoom armed state, the disabled button and fields, the zoom-locked styling, and position.loopZoomLocked in all nine language tables. The test that asserted the old behaviour now asserts its opposite: a loop survives zooming to 5x and back with identical bounds, identical region geometry and still running. Checked against a build that clears the loop on zoom, which fails it. --- static/css/daw.css | 12 ------- static/js/i18n.js | 9 ----- static/js/transport.js | 47 +------------------------ tests/e2e/zoom.spec.mjs | 78 ++++++++++++++++++++++++++++++++--------- 4 files changed, 63 insertions(+), 83 deletions(-) diff --git a/static/css/daw.css b/static/css/daw.css index e338699e..14dbd481 100644 --- a/static/css/daw.css +++ b/static/css/daw.css @@ -2053,18 +2053,6 @@ input, textarea { font-family: inherit; } .loop-region.hidden { display: none !important; } -/* ── Waveform zoom ── */ -/* Zoomed in, the loop tools are inert (see loopToolsAvailable in transport.js): - a drag would define a region whose ends are off screen. Say so rather than - letting the controls look live and do nothing. The crosshair is the "you can - drag a loop here" affordance, so it goes too. */ -.loop-btn.zoom-locked, -#t-loop.zoom-locked { - opacity: 0.4; - cursor: not-allowed; -} -.waves-column.zoom-locked { cursor: default; } -.daw .lanes-ruler-time.zoom-locked { cursor: default; } /* Wave loading overlay */ .wave-loading-overlay { diff --git a/static/js/i18n.js b/static/js/i18n.js index de73655b..884e6749 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -399,7 +399,6 @@ const en = { "position.group": "Position", "position.loopTitle": "Loop the selected position (L)", - "position.loopZoomLocked": "Looping is available at 1x zoom. Reset the zoom to use it.", "position.loopLabel": "Loop position", "position.loopExactTitle": "Exact loop start / end (mm:ss.mmm or seconds)", "position.loopStartAria": "Loop start", @@ -951,7 +950,6 @@ const pl = { "position.group": "Pozycja", "position.loopTitle": "Zapętl zaznaczoną pozycję (L)", - "position.loopZoomLocked": "Zapętlanie działa przy powiększeniu 1x. Zresetuj powiększenie, aby go użyć.", "position.loopLabel": "Pętla pozycji", "position.loopExactTitle": "Dokładny początek / koniec pętli (mm:ss.mmm lub sekundy)", "position.loopStartAria": "Początek pętli", @@ -1494,7 +1492,6 @@ const ja = { "position.group": "位置", "position.loopTitle": "選択した位置をループ (L)", - "position.loopZoomLocked": "ループはズーム 1x でのみ使えます。ズームをリセットしてください。", "position.loopLabel": "位置をループ", "position.loopExactTitle": "正確なループ開始/終了 (mm:ss.mmm または秒)", "position.loopStartAria": "ループ開始", @@ -2013,7 +2010,6 @@ const zhHans = { "position.group": "位置", "position.loopTitle": "循环所选位置 (L)", - "position.loopZoomLocked": "循环仅在 1x 缩放下可用。请重置缩放后使用。", "position.loopLabel": "循环位置", "position.loopExactTitle": "精确循环起止点 (mm:ss.mmm 或秒)", "position.loopStartAria": "循环起点", @@ -2533,7 +2529,6 @@ const de = { "position.group": "Position", "position.loopTitle": "Ausgewählte Position loopen (L)", - "position.loopZoomLocked": "Loopen ist bei 1x-Zoom verfügbar. Zoom zurücksetzen, um es zu nutzen.", "position.loopLabel": "Loop-Position", "position.loopExactTitle": "Genauer Loop-Start/-Ende (mm:ss.mmm oder Sekunden)", "position.loopStartAria": "Loop-Start", @@ -3063,7 +3058,6 @@ const pt = { "position.group": "Posição", "position.loopTitle": "Repetir a posição selecionada (L)", - "position.loopZoomLocked": "O loop fica disponível no zoom 1x. Redefina o zoom para usá-lo.", "position.loopLabel": "Posição de loop", "position.loopExactTitle": "Início/fim exato do loop (mm:ss.mmm ou segundos)", "position.loopStartAria": "Início do loop", @@ -3594,7 +3588,6 @@ const id = { "position.group": "Posisi", "position.loopTitle": "Loop posisi yang dipilih (L)", - "position.loopZoomLocked": "Loop tersedia pada zoom 1x. Atur ulang zoom untuk memakainya.", "position.loopLabel": "Posisi loop", "position.loopExactTitle": "Awal/akhir loop yang tepat (mm:ss.mmm atau detik)", "position.loopStartAria": "Awal loop", @@ -4114,7 +4107,6 @@ const fr = { "position.group": "Position", "position.loopTitle": "Boucler la position sélectionnée (L)", - "position.loopZoomLocked": "La boucle est disponible au zoom 1x. Réinitialisez le zoom pour l'utiliser.", "position.loopLabel": "Boucler la position", "position.loopExactTitle": "Début / fin exacts de la boucle (mm:ss.mmm ou secondes)", "position.loopStartAria": "Début de la boucle", @@ -4745,7 +4737,6 @@ const es = { "position.group": "Posición", "position.loopTitle": "Repetir en loop la posición seleccionada (L)", - "position.loopZoomLocked": "El bucle está disponible con zoom 1x. Restablece el zoom para usarlo.", "position.loopLabel": "Loop de la posición", "position.loopExactTitle": "Inicio / fin exacto del loop (mm:ss.mmm o segundos)", "position.loopStartAria": "Inicio del loop", diff --git a/static/js/transport.js b/static/js/transport.js index 90e3a1db..7f4d2f69 100644 --- a/static/js/transport.js +++ b/static/js/transport.js @@ -241,7 +241,7 @@ export function updateLoopRegionVisual() { // programmatic change (drag, toggle). Never overwrite a field the user is // actively editing, and disable both when no track is loaded. function syncLoopInputs() { - const enabled = totalDuration > 0 && loopToolsAvailable(); + const enabled = totalDuration > 0; for (const [input, value] of [ [loopStartInput, loopStart], [loopEndInput, loopEnd], @@ -422,7 +422,6 @@ export function stopTransport() { } export function toggleLoop() { - if (!loopToolsAvailable()) return; setLoopEnabled(!loopEnabled); loopBtn.classList.toggle("active", loopEnabled); updateLoopRegionVisual(); @@ -439,9 +438,6 @@ function wireLoopDrag() { const startDrag = (e, surface) => { if (e.button !== 0 || e.target.closest(".loop-region")) return; - // Zoomed in, a drag would define a region whose ends are off screen and - // whose overlay is positioned against a timeline the user cannot see. - if (!loopToolsAvailable()) return; const t = timeFromClientX(e.clientX); if (t === null) return; dragging = true; @@ -557,45 +553,6 @@ export function applyWaveZoom() { } } -// The loop tools belong to the whole-track view. Zoomed in, a drag selects a -// region the user cannot see the ends of, and the region overlay is positioned -// as a percentage of a timeline that is now mostly off screen. So they are -// available at 1x and inert everywhere else. -// -// The bounds themselves are never discarded. Zooming out restores the loop -// exactly as it was, including whether it was running. -let _loopArmedBeforeZoom = false; - -export function loopToolsAvailable() { - return waveZoom === WAVE_ZOOM_MIN; -} - -function syncLoopAvailability() { - const available = loopToolsAvailable(); - if (!available && loopEnabled) { - _loopArmedBeforeZoom = true; - setLoopEnabled(false); - loopBtn.classList.remove("active"); - updateLoopRegionVisual(); - } else if (available && _loopArmedBeforeZoom) { - _loopArmedBeforeZoom = false; - setLoopEnabled(true); - loopBtn.classList.add("active"); - updateLoopRegionVisual(); - } - if (loopBtn) { - loopBtn.disabled = !available; - loopBtn.classList.toggle("zoom-locked", !available); - loopBtn.title = available ? t("position.loopTitle") : t("position.loopZoomLocked"); - } - for (const input of [loopStartInput, loopEndInput]) { - if (!input) continue; - input.disabled = !available || totalDuration <= 0; - } - document.querySelector(".waves-column")?.classList.toggle("zoom-locked", !available); - rulerTime?.classList.toggle("zoom-locked", !available); -} - /** * Set the zoom, keeping the time under `anchorClientX` where it is. * @@ -615,7 +572,6 @@ export function setWaveZoomLevel(next, anchorClientX = null) { setWaveZoom(clamped); applyWaveZoom(); - syncLoopAvailability(); // Everything positioned against the timeline is laid out again at the new // width: the ruler because its ticks are now the wrong distance apart for the // detail on screen, the playhead and the loop region because buildRuler @@ -674,7 +630,6 @@ function wireZoomButtons() { waveScroll.addEventListener("scroll", syncRulerScroll, { passive: true }); } applyWaveZoom(); - syncLoopAvailability(); } // Keep the mixer column and the waveform area scrolled in lockstep so stem diff --git a/tests/e2e/zoom.spec.mjs b/tests/e2e/zoom.spec.mjs index b6672f9b..0ee4152b 100644 --- a/tests/e2e/zoom.spec.mjs +++ b/tests/e2e/zoom.spec.mjs @@ -40,6 +40,20 @@ async function zoomState(page) { }); } +// The loop bounds as the app holds them, not as they are drawn: the region is +// positioned in percentages, so reading the element back would only prove the +// percentages agree with themselves. +const loopBounds = (page) => + page.evaluate(() => { + const el = document.getElementById("loop-region"); + const parent = el.parentElement.getBoundingClientRect(); + const box = el.getBoundingClientRect(); + return { + start: +((box.left - parent.left) / parent.width).toFixed(4), + end: +((box.right - parent.left) / parent.width).toFixed(4), + }; + }); + // Deliberately wide: the bar-count maths only has room to prove itself when the // panel can hold a few hundred bars. test.use({ viewport: { width: 1600, height: 900 } }); @@ -116,7 +130,7 @@ test.describe("waveform zoom", () => { expect(Math.abs(nowUnderPointer - timeUnderPointer)).toBeLessThan(0.005); }); - test("the loop tools are inert while zoomed and come back at 1x", async ({ page }) => { + test("a loop survives zooming in and out untouched", async ({ page }) => { await openStudio(page, { tauri: true }); // The ruler, not the lane body: the fixture keeps its loading overlay over @@ -134,25 +148,57 @@ test.describe("waveform zoom", () => { await dragRuler(0.2, 0.6); await expect(page.locator("#t-loop")).toHaveClass(/active/); const armed = await page.locator("#loop-region").getAttribute("style"); + const bounds = await loopBounds(page); + expect(bounds.end).toBeGreaterThan(bounds.start); - await wheel(page, -300); - const zoomed = await zoomState(page); - expect(zoomed.loopDisabled).toBe(true); - expect(zoomed.loopStartDisabled).toBe(true); - await expect(page.locator("#t-loop")).not.toHaveClass(/active/); - await expect(page.locator("#loop-region")).toHaveClass(/hidden/); + // Zoom is a view change, not an edit. Nothing about the loop may move. + for (let i = 0; i < 12; i++) await wheel(page, -240); + await expect(page.locator("#t-loop")).toHaveClass(/active/); + await expect(page.locator("#loop-region")).not.toHaveClass(/hidden/); + expect(await loopBounds(page)).toEqual(bounds); + expect((await zoomState(page)).loopDisabled).toBe(false); + + for (let i = 0; i < 30; i++) await wheel(page, 240); + expect(await loopBounds(page)).toEqual(bounds); + // Same percentages, so the region lands back on exactly the same span. + expect(await page.locator("#loop-region").getAttribute("style")).toBe(armed); + await expect(page.locator("#t-loop")).toHaveClass(/active/); + }); - // A drag while zoomed must not define a new region. - await dragRuler(0.05, 0.35); - await expect(page.locator("#loop-region")).toHaveClass(/hidden/); + test("a loop can be marked while zoomed in", async ({ page }) => { + await openStudio(page, { tauri: true }); + for (let i = 0; i < 12; i++) await wheel(page, -240); + const zoomed = await zoomState(page); + expect(zoomed.content).toBeGreaterThan(zoomed.viewport); + expect(zoomed.loopDisabled).toBe(false); + expect(zoomed.loopStartDisabled).toBe(false); + + // Drag across the visible slice. The ruler is translated by scrollLeft, so + // its box is the whole zoomed timeline and a drag inside the viewport marks + // the times actually under the pointer -- which is the thing that has to + // hold once loops can be made at any zoom. + const ruler = await page.locator("#ruler-time").boundingBox(); + const view = await page.locator(".wave-scroll").boundingBox(); + const y = view.y + 10; + const fromX = view.x + view.width * 0.3; + const toX = view.x + view.width * 0.7; + await page.mouse.move(fromX, y); + await page.mouse.down(); + await page.mouse.move(toX, y, { steps: 10 }); + await page.mouse.up(); - // Back at 1x the original loop returns, unchanged. - for (let i = 0; i < 30; i++) await wheel(page, 300); - const back = await zoomState(page); - expect(back.content).toBe(back.viewport); - expect(back.loopDisabled).toBe(false); await expect(page.locator("#t-loop")).toHaveClass(/active/); - expect(await page.locator("#loop-region").getAttribute("style")).toBe(armed); + const bounds = await loopBounds(page); + + // Where the pointer actually was, as a fraction of the whole timeline, + // taken from the ruler's own box: it is translated by scrollLeft, so its + // left edge is off screen and this is the only honest reference. + const expectStart = (fromX - ruler.x) / ruler.width; + const expectEnd = (toX - ruler.x) / ruler.width; + expect(bounds.start).toBeCloseTo(expectStart, 2); + expect(bounds.end).toBeCloseTo(expectEnd, 2); + // A drag across part of a zoomed view marks a slice, not the whole track. + expect(bounds.end - bounds.start).toBeLessThan(0.2); }); test("the ruler gets finer as you zoom, and the footer strip does not", async ({ page }) => { From 5ba0d242835d9593a1d8de57c10df059520af76e Mon Sep 17 00:00:00 2001 From: Thales <> Date: Sat, 29 Aug 2026 15:55:43 +0100 Subject: [PATCH 3/3] Fix five defects the zoom introduced Found by auditing the zoom rather than by hitting them, so each is stated with what it would have cost. The decoded overview was rescanned on every wheel notch. bufferMinMaxPeaks walks every sample of every stem, and asking it for a resolution derived from the current bar count meant re-reading the whole song per stem on each notch -- tens of millions of samples per step on a four minute track. It is now scanned once per track at the finest resolution any zoom will ask for, and the bars are downsampled from that. This also collapses the two overview sources into one: peaks.json and the decoded buffers are the same shape from here on, they only differ in how many points they carry, so the re-render no longer branches. The anchor was derived from the zoom ratio, not the measured width. WAVE_MIN_WIDTH floors the content at 720px, so on a narrow window a step can widen the content by less than its own factor or not at all, and scaling the scroll by the ratio slid the time out from under the pointer. Measured before and after instead. Covered at 1150px, where the wave area is 460px and the floor is genuinely active. A resize left the ruler at the old width's tick density. Tick spacing is now chosen from the content width, so it has to be reconsidered when that width changes. The rebuild is guarded on the step actually differing: buildRuler writes inside the element the resize observer watches, and rebuilding unconditionally would feed the observer its own output. Zooming with no time source parked the playhead at 0. buildRuler re-creates the marker element, so it has to be put back where the transport is -- and if nothing can say where that is, left alone rather than sent to the start. Switching tracks painted the old loop for a frame. resetWaveZoom redraws the loop region, and it ran before the previous track's loop had been cleared, so the region was briefly drawn against the new track's duration. Moved after. One more, in the pan gesture rather than the zoom: shift plus a horizontal trackpad swipe reports deltaY 0, and the axis was picked in a way that resolved to zero and scrolled nothing. It now takes whichever axis actually carried the gesture. Two test findings worth recording. The lane-body drag is unreliable until the wave-loading overlay clears -- measured at three arms in six attempts on the fixture, nothing to do with zoom -- so tests that drag on the lanes now wait for it. And the two new anchor and performance guarantees were each checked against a build with the old behaviour restored, which fails them. --- static/js/player.js | 48 ++++++++++++++++------------------------- static/js/transport.js | 45 ++++++++++++++++++++++++++++++++------ tests/e2e/zoom.spec.mjs | 38 ++++++++++++++++++++++++++++++++ 3 files changed, 96 insertions(+), 35 deletions(-) diff --git a/static/js/player.js b/static/js/player.js index 237071b1..b284861a 100644 --- a/static/js/player.js +++ b/static/js/player.js @@ -42,7 +42,8 @@ import { } from "./mixer.js"; import { buildRuler, updatePlayheadMarker, updateLoopRegionVisual, - applyWaveZoom, resetWaveZoom, buildPresenceRuler, buildFooterWaveTicks, updateFooterTimes, + applyWaveZoom, resetWaveZoom, WAVE_ZOOM_MAX, + buildPresenceRuler, buildFooterWaveTicks, updateFooterTimes, updatePresencePlayhead, resetSpeed, resetPitch, updatePitchAvailability, updateMetronomeAvailability, applyMetronomeAccent, } from "./transport.js"; @@ -442,14 +443,12 @@ let _overviewSource = null; function rerenderOverviewWaveforms() { if (!_overviewSource) return; - const { kind, stems, data } = _overviewSource; - if (kind === "peaks") renderAllOverviewWaveformsFromPeaks(stems, data); - else renderAllOverviewWaveforms(stems, data); + renderAllOverviewWaveformsFromPeaks(_overviewSource.stems, _overviewSource.data); } setOverviewRerenderFn(rerenderOverviewWaveforms); function renderAllOverviewWaveformsFromPeaks(stems, peaksData) { - _overviewSource = { kind: "peaks", stems, data: peaksData }; + _overviewSource = { stems, data: peaksData }; const laneNames = overviewLaneNames(stems); // Only the extracted/selected stems (plus original) get a waveform, even if // peaks.json carries data for stems the user didn't keep (Demucs separates @@ -479,31 +478,20 @@ function renderAllOverviewWaveformsFromPeaks(stems, peaksData) { // matching what a DAW shows. Per-stem normalization made every lane // fill its row regardless of how loud the stem actually was. function renderAllOverviewWaveforms(stems, decodedMap) { - // Decoded buffers beat peaks.json: bufferMinMaxPeaks can be asked for as many - // points as the zoom needs, where peaks.json is fixed at 1500. - _overviewSource = { kind: "decoded", stems, data: decodedMap }; const laneNames = overviewLaneNames(stems); - const peaksByStem = new Map(); - let globalMax = 0; + const peaksByStem = {}; for (const name of laneNames) { const buf = decodedMap.get(name); if (!isAudioBufferLike(buf)) continue; - // Enough points for the bars actually being drawn. At 1x that is well under - // OVERVIEW_WAVE_POINTS, but a wide panel at 5x asks for more bars than 1500, - // and short of that every extra bar would repeat its neighbour's sample and - // the zoom would show stair-steps instead of detail. - const peaks = bufferMinMaxPeaks(buf, Math.max(OVERVIEW_WAVE_POINTS, overviewBarCount())); - peaksByStem.set(name, peaks); - for (const [mn, mx] of peaks) { - if (mx > globalMax) globalMax = mx; - if (-mn > globalMax) globalMax = -mn; - } + // Scanned once per track, at the finest resolution any zoom will ask for. + // bufferMinMaxPeaks walks every sample, so doing this per zoom step would + // re-read the whole song per stem on each wheel notch. The bars are + // downsampled from this cache instead, which is what peaks.json already is + // -- the two sources are the same shape from here on, they just differ in + // how many points they carry. + peaksByStem[name] = bufferMinMaxPeaks(buf, OVERVIEW_WAVE_POINTS * WAVE_ZOOM_MAX); } - const norm = globalMax > 0 ? 1 / globalMax : 0; - const bars = overviewBarCount(); - laneNames.forEach((name, i) => { - renderOverviewWaveformPath(name, peaksByStem.get(name), norm, STEM_COLORS[name] || "#a0a0a0", bars, i); - }); + renderAllOverviewWaveformsFromPeaks(stems, peaksByStem); } function renderDecodedStemVisuals(stemName, audioBuffer, color) { @@ -1032,10 +1020,6 @@ export function wireUpAudio(jobId, stems, duration, thumbnail, mixUrl = null, ti }, 60000); setCurrentJobId(jobId); setTotalDuration(duration || 0); - // A new track starts fitted. Carrying the previous track's zoom over would - // open it scrolled into the middle of a song the user has not seen yet, with - // the loop tools silently unavailable. - resetWaveZoom(); refreshMixerVisuals(); const mixReady = loadMixIntoState(jobId, stems.map((s) => s.name)) .then(() => { if (currentJobId === jobId) refreshMixerVisuals(); }) @@ -1046,6 +1030,12 @@ export function wireUpAudio(jobId, stems, duration, thumbnail, mixUrl = null, ti setLoopEnd(0); loopBtn.classList.remove("active"); loopRegionEl.classList.add("hidden"); + // After the loop is cleared, never before: resetWaveZoom redraws the loop + // region, so running it first would paint the previous track's loop against + // this track's duration for a frame. A new track also starts fitted -- the + // previous track's zoom would open this one scrolled into the middle of a + // song the user has not seen yet. + resetWaveZoom(); // Refresh loop UI so the exact-loop inputs enable + reset to 00:00.000 now // that the track duration is known. updateLoopRegionVisual(); diff --git a/static/js/transport.js b/static/js/transport.js index 7f4d2f69..7b6e92fc 100644 --- a/static/js/transport.js +++ b/static/js/transport.js @@ -33,7 +33,7 @@ const MIN_LOOP_SEC = 0.2; // panel asks for more bars than there are samples behind them, and the extra // bars repeat their neighbours instead of revealing anything. const WAVE_ZOOM_MIN = 1; -const WAVE_ZOOM_MAX = 5; +export const WAVE_ZOOM_MAX = 5; // One wheel notch. Multiplicative, so a notch covers the same proportion of the // range at 1x as at 4x; linear steps feel fast at the bottom and stuck at the top. const WAVE_ZOOM_STEP = 1.18; @@ -91,6 +91,12 @@ const TICK_LADDER = [1, 2, 5, 10, 15, 30, 60, 120, 300]; // `contentWidthPx` is the width the ticks will actually occupy. Omitted (the // footer strip, which always shows the whole track) the step is the plain // duration-based one, which is also what 1x has always used. +// The step the ruler was last built with. buildRuler mutates elements inside +// .wave-scroll, which is the element the resize observer watches, so rebuilding +// unconditionally from that callback can re-trigger it. Comparing against this +// makes the rebuild idempotent: once the ruler matches the width, it settles. +let _rulerStep = 0; + function tickStep(durationSec, contentWidthPx = 0) { const base = durationSec < 90 ? 15 : durationSec < 300 ? 30 : 60; // Zoom is the only thing that subdivides it. Spreading the same handful of @@ -119,6 +125,7 @@ export function buildRuler(durationSec) { // The ruler is width: calc(100% * var(--zoom)), so its own box already is the // zoomed width; no need to recompute it here. const step = tickStep(durationSec, rulerTime.getBoundingClientRect().width); + _rulerStep = step; for (let t = 0; t <= durationSec; t += step) { const leftPct = (t / durationSec) * 100; const tick = document.createElement("div"); @@ -562,28 +569,38 @@ export function applyWaveZoom() { export function setWaveZoomLevel(next, anchorClientX = null) { const clamped = Math.min(WAVE_ZOOM_MAX, Math.max(WAVE_ZOOM_MIN, next)); if (Math.abs(clamped - waveZoom) < 1e-4) return false; - const previous = waveZoom; // Which content pixel the anchor is on, before anything moves. const rect = waveScroll?.getBoundingClientRect(); const offsetX = anchorClientX !== null && rect ? Math.min(rect.width, Math.max(0, anchorClientX - rect.left)) : (waveScroll ? waveScroll.clientWidth / 2 : 0); const contentX = (waveScroll?.scrollLeft ?? 0) + offsetX; + // Measured, not derived from the zoom ratio. WAVE_MIN_WIDTH floors the + // content width, so on a window narrower than 720px a zoom step can widen the + // content by less than its own factor -- or not at all -- and scaling by the + // ratio would slide the anchor out from under the pointer. + const beforeWidth = waveCanvas?.getBoundingClientRect().width || 0; setWaveZoom(clamped); applyWaveZoom(); + const afterWidth = waveCanvas?.getBoundingClientRect().width || beforeWidth; + const growth = beforeWidth > 0 ? afterWidth / beforeWidth : 1; // Everything positioned against the timeline is laid out again at the new // width: the ruler because its ticks are now the wrong distance apart for the // detail on screen, the playhead and the loop region because buildRuler // rebuilds the elements they live in. buildRuler(totalDuration); - updatePlayheadMarker((audioEngine ?? multitrack)?.getCurrentTime?.() ?? 0); + // buildRuler re-creates the marker element, so it comes back at 0. Put it + // back where the transport actually is -- but only if something can say; + // defaulting to 0 would yank the playhead to the start of the track. + const now = (audioEngine ?? multitrack)?.getCurrentTime?.(); + if (typeof now === "number") updatePlayheadMarker(now); updateLoopRegionVisual(); if (waveScroll) { // The same content pixel after the widening, minus where it sits in the // viewport, is the scroll offset that leaves it under the pointer. - const target = contentX * (clamped / previous) - offsetX; + const target = contentX * growth - offsetX; waveScroll.scrollLeft = Math.max(0, target); syncRulerScroll(); } @@ -600,7 +617,20 @@ function wireZoomButtons() { const ro = new ResizeObserver(() => { if (!multitrack || totalDuration <= 0) return; if (rafId) cancelAnimationFrame(rafId); - rafId = requestAnimationFrame(() => { rafId = null; applyWaveZoom(); }); + rafId = requestAnimationFrame(() => { + rafId = null; + applyWaveZoom(); + // Tick spacing is chosen from the content width, so a resize can leave + // the ruler at the density the old width called for. Rebuild only when + // the step it would pick has actually changed: buildRuler writes inside + // the observed element, so rebuilding every time would feed the + // observer its own output. + const next = tickStep(totalDuration, rulerTime?.getBoundingClientRect().width || 0); + if (next !== _rulerStep) { + buildRuler(totalDuration); + updateLoopRegionVisual(); + } + }); }); ro.observe(waveScroll); } @@ -613,7 +643,10 @@ function wireZoomButtons() { if (e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY)) { if (waveScroll.scrollWidth <= waveScroll.clientWidth) return; e.preventDefault(); - waveScroll.scrollLeft += (e.shiftKey ? e.deltaY : e.deltaX) || e.deltaY; + // Whichever axis the gesture actually carried. Shift-wheel puts it on + // deltaY, a trackpad swipe on deltaX, and shift plus a swipe on deltaX + // with deltaY at zero. + waveScroll.scrollLeft += Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY; syncRulerScroll(); return; } diff --git a/tests/e2e/zoom.spec.mjs b/tests/e2e/zoom.spec.mjs index 0ee4152b..a58b77ba 100644 --- a/tests/e2e/zoom.spec.mjs +++ b/tests/e2e/zoom.spec.mjs @@ -43,6 +43,17 @@ async function zoomState(page) { // The loop bounds as the app holds them, not as they are drawn: the region is // positioned in percentages, so reading the element back would only prove the // percentages agree with themselves. +// The lane body is under the wave-loading overlay until the waveforms have +// rendered, and a drag that lands on the overlay is swallowed. Measured on the +// fixture: without this wait a 1x lane drag arms the loop 3 times in 6. Nothing +// to do with zoom, but every test that drags on the lanes has to wait for it. +const lanesReady = (page) => + page.waitForFunction( + () => document.getElementById("waveLoadingOverlay")?.classList.contains("hidden") !== false, + null, + { timeout: 20000 }, + ); + const loopBounds = (page) => page.evaluate(() => { const el = document.getElementById("loop-region"); @@ -167,6 +178,7 @@ test.describe("waveform zoom", () => { test("a loop can be marked while zoomed in", async ({ page }) => { await openStudio(page, { tauri: true }); + await lanesReady(page); for (let i = 0; i < 12; i++) await wheel(page, -240); const zoomed = await zoomState(page); expect(zoomed.content).toBeGreaterThan(zoomed.viewport); @@ -201,6 +213,32 @@ test.describe("waveform zoom", () => { expect(bounds.end - bounds.start).toBeLessThan(0.2); }); + // WAVE_MIN_WIDTH floors the content width at 720px, so on a narrow window the + // first zoom step can widen the content by less than its own factor, or not at + // all. Deriving the scroll correction from the zoom ratio rather than the + // measured width slides the anchor out from under the pointer exactly here. + // 1150px leaves the wave area 460px wide, where the floor is active. + test("the anchor holds where the minimum wave width floors the growth", async ({ page }) => { + await page.setViewportSize({ width: 1150, height: 800 }); + await openStudio(page, { tauri: true }); + const rect = await page.locator(".wave-scroll").boundingBox(); + const anchorX = rect.x + rect.width * 0.6; + + const before = await zoomState(page); + // The floor is genuinely in play: the content is already wider than the + // viewport at 1x, so the first notch cannot widen it proportionally. + expect(before.content).toBeGreaterThan(before.viewport); + + const held = (s) => (s.scrollLeft + (anchorX - rect.x)) / s.content; + const target = held(before); + + for (let i = 0; i < 3; i++) await wheel(page, -240, anchorX, rect.y + 40); + const after = await zoomState(page); + + expect(after.content).toBeGreaterThan(before.content); + expect(Math.abs(held(after) - target)).toBeLessThan(0.01); + }); + test("the ruler gets finer as you zoom, and the footer strip does not", async ({ page }) => { await openStudio(page, { tauri: true }); const base = await zoomState(page);