Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions static/css/daw.css
Original file line number Diff line number Diff line change
Expand Up @@ -2053,6 +2053,7 @@ input, textarea { font-family: inherit; }

.loop-region.hidden { display: none !important; }


/* Wave loading overlay */
.wave-loading-overlay {
position: absolute; inset: 0; z-index: 10;
Expand Down
47 changes: 32 additions & 15 deletions static/js/player.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -42,7 +42,8 @@ import {
} from "./mixer.js";
import {
buildRuler, updatePlayheadMarker, updateLoopRegionVisual,
applyWaveZoom, buildPresenceRuler, buildFooterWaveTicks, updateFooterTimes,
applyWaveZoom, resetWaveZoom, WAVE_ZOOM_MAX,
buildPresenceRuler, buildFooterWaveTicks, updateFooterTimes,
updatePresencePlayhead, resetSpeed, resetPitch, updatePitchAvailability,
updateMetronomeAvailability, applyMetronomeAccent,
} from "./transport.js";
Expand Down Expand Up @@ -433,7 +434,21 @@ 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;
renderAllOverviewWaveformsFromPeaks(_overviewSource.stems, _overviewSource.data);
}
setOverviewRerenderFn(rerenderOverviewWaveforms);

function renderAllOverviewWaveformsFromPeaks(stems, 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
Expand Down Expand Up @@ -464,23 +479,19 @@ function renderAllOverviewWaveformsFromPeaks(stems, peaksData) {
// fill its row regardless of how loud the stem actually was.
function renderAllOverviewWaveforms(stems, 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;
const peaks = bufferMinMaxPeaks(buf, OVERVIEW_WAVE_POINTS);
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) {
Expand Down Expand Up @@ -1019,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();
Expand Down
14 changes: 14 additions & 0 deletions static/js/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand All @@ -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.
Expand Down
146 changes: 137 additions & 9 deletions static/js/transport.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,24 @@ 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";
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;
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;
// 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;
Expand Down Expand Up @@ -72,8 +83,32 @@ 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.
// 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
// 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) {
Expand All @@ -87,7 +122,10 @@ 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);
_rulerStep = step;
for (let t = 0; t <= durationSec; t += step) {
const leftPct = (t / durationSec) * 100;
const tick = document.createElement("div");
Expand Down Expand Up @@ -494,8 +532,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.
Expand All @@ -508,29 +548,117 @@ 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();
});
}
}
}

/**
* 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;
// 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);
// 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 * growth - 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;
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);
}
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;
// 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;
}
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 });
}
Expand Down
Loading
Loading