8f10353f0c
CI / lint (push) Has been cancelled
CI / js-syntax (push) Successful in 10m24s
CI / deps-audit (push) Has been cancelled
CI / test (push) Failing after 24m55s
CI / sast-bandit (push) Failing after 11m13s
CI / trivy (push) Failing after 9m32s
Docker Publish / build-and-push (push) Failing after 34m3s
537 lines
20 KiB
JavaScript
537 lines
20 KiB
JavaScript
import { fmtTime, fmtTickLabel, fmtTimeMs, parseTimecode } from "./utils.js";
|
|
import {
|
|
playBtn, playMiniBtn, stopBtn, loopBtn, timeEl, masterFader,
|
|
speedEl, speedLabelEl,
|
|
rulerTime, wavesGrid, loopRegionEl, playheadMarker,
|
|
multitrack, audioEngine, totalDuration, loopEnabled, loopStart, loopEnd, masterVolume,
|
|
waveScroll, waveCanvas, multitrackContainer,
|
|
presenceRulerEl, presencePlayheadEl,
|
|
footerTimeElapsed, footerTimeTotal, npScrubFill, footerWaveDrawFn,
|
|
loopStartInput, loopEndInput,
|
|
setLoopEnabled, setLoopStart, setLoopEnd, setMasterVolume, setPlaybackSpeed,
|
|
} from "./state.js";
|
|
import { applyMix } from "./mixer.js";
|
|
|
|
const MIN_LOOP_SEC = 0.2;
|
|
// 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;
|
|
// rulerTime is the canonical timeline reference for both click->time
|
|
// and time->pixel mapping. The wave-editor lays the ruler and the
|
|
// waveform body out so they should be horizontally aligned (both gutter
|
|
// 48 px on the left in studio mode), but using one element for both
|
|
// halves of the round-trip eliminates any subtle CSS drift -- clicking
|
|
// "1:00" on the ruler always lands a marker exactly under that tick,
|
|
// regardless of how the waves layer below happens to size itself.
|
|
function rulerRect() {
|
|
return rulerTime?.getBoundingClientRect() || { left: 0, width: 1 };
|
|
}
|
|
|
|
function loopOverlayParent() {
|
|
return document.querySelector(".waves-column") || rulerTime;
|
|
}
|
|
|
|
function ensureLoopRegionParent() {
|
|
const parent = loopOverlayParent();
|
|
if (parent && loopRegionEl.parentElement !== parent) {
|
|
parent.appendChild(loopRegionEl);
|
|
}
|
|
}
|
|
|
|
function timeFromClientX(clientX) {
|
|
if (!totalDuration) return null;
|
|
const rect = rulerRect();
|
|
const x = clientX - rect.left;
|
|
const frac = Math.max(0, Math.min(1, x / Math.max(1, rect.width)));
|
|
return frac * totalDuration;
|
|
}
|
|
|
|
function setPlayheadTime(sec) {
|
|
const tx = audioEngine ?? multitrack;
|
|
if (!tx || !totalDuration) return;
|
|
const next = Math.max(0, Math.min(totalDuration, sec));
|
|
tx.setTime(next);
|
|
updatePlayheadMarker(next);
|
|
updateFooterTimes(next);
|
|
updatePresencePlayhead(next);
|
|
}
|
|
|
|
export function buildRuler(durationSec) {
|
|
rulerTime.innerHTML = "";
|
|
wavesGrid.innerHTML = "";
|
|
const marker = document.createElement("div");
|
|
marker.className = "playhead-marker";
|
|
marker.setAttribute("aria-hidden", "true");
|
|
marker.innerHTML =
|
|
'<svg viewBox="0 0 10 10" width="10" height="10"><polygon points="0,0 10,0 5,8" fill="#e54e4e"></polygon></svg>';
|
|
rulerTime.appendChild(marker);
|
|
|
|
if (!durationSec || durationSec <= 0) return;
|
|
const step = durationSec < 90 ? 15 : durationSec < 300 ? 30 : 60;
|
|
for (let t = 0; t <= durationSec; t += step) {
|
|
const leftPct = (t / durationSec) * 100;
|
|
const tick = document.createElement("div");
|
|
tick.className = "tick";
|
|
tick.style.left = `${leftPct}%`;
|
|
tick.innerHTML = `<span class="tick-label">${fmtTickLabel(t)}</span>`;
|
|
rulerTime.appendChild(tick);
|
|
|
|
const grid = document.createElement("div");
|
|
grid.className = "grid-line";
|
|
grid.style.left = `${leftPct}%`;
|
|
wavesGrid.appendChild(grid);
|
|
}
|
|
}
|
|
|
|
export function updatePlayheadMarker(currentSec) {
|
|
if (!playheadMarker || !totalDuration) return;
|
|
const m = rulerTime.querySelector(".playhead-marker");
|
|
if (m) {
|
|
// Position relative to the ruler itself (the marker is a ruler
|
|
// child) so the playhead always sits exactly under the tick at
|
|
// the matching time. Use percent instead of px: app-level CSS
|
|
// zoom scales getBoundingClientRect() values, while left/width
|
|
// styles are interpreted in unzoomed layout pixels.
|
|
const pct = Math.max(0, Math.min(100, (currentSec / totalDuration) * 100));
|
|
m.style.left = `${pct}%`;
|
|
}
|
|
}
|
|
|
|
// Mirror the elapsed/total time into the transport-footer's two side
|
|
// labels (which used to show hardcoded "00:00.000" / "03:38.000") and
|
|
// drive the small scrub bar in the now-playing card. Driven from the
|
|
// same wavesurfer "timeupdate" event that already updates #t-time, so
|
|
// every label stays in sync without extra event plumbing.
|
|
export function updateFooterTimes(currentSec) {
|
|
if (!totalDuration) return;
|
|
if (footerTimeElapsed) footerTimeElapsed.textContent = fmtTime(currentSec);
|
|
if (footerTimeTotal) footerTimeTotal.textContent = fmtTime(totalDuration);
|
|
const pct = Math.max(0, Math.min(100, (currentSec / totalDuration) * 100));
|
|
if (npScrubFill) npScrubFill.style.width = `${pct}%`;
|
|
footerWaveDrawFn?.(pct / 100);
|
|
}
|
|
|
|
// Build the presence-panel ruler labels from the actual track duration.
|
|
// The HTML ships 8 placeholder <b> tags ("0:00 ... 3:38"); we replace
|
|
// each label's text with a tick at evenly-spaced fractions of the song.
|
|
export function buildPresenceRuler(durationSec) {
|
|
if (!presenceRulerEl) return;
|
|
const ticks = presenceRulerEl.querySelectorAll("b");
|
|
if (!ticks.length) return;
|
|
if (!durationSec || durationSec <= 0) {
|
|
for (const t of ticks) t.textContent = "0:00";
|
|
return;
|
|
}
|
|
// 8 ticks -- evenly distribute from 0 to duration.
|
|
const n = ticks.length;
|
|
for (let i = 0; i < n; i++) {
|
|
const frac = i / (n - 1);
|
|
ticks[i].textContent = fmtTickLabel(frac * durationSec);
|
|
}
|
|
}
|
|
|
|
// Move the gold playhead line that overlays the presence-bars panel.
|
|
// Uses left% within the .presence-bars container, which spans the full
|
|
// duration -- matches the ruler ticks above it.
|
|
export function updatePresencePlayhead(currentSec) {
|
|
if (!presencePlayheadEl) return;
|
|
if (!totalDuration || totalDuration <= 0) {
|
|
presencePlayheadEl.classList.add("hidden");
|
|
return;
|
|
}
|
|
const pct = Math.max(0, Math.min(100, (currentSec / totalDuration) * 100));
|
|
presencePlayheadEl.style.left = `${pct}%`;
|
|
presencePlayheadEl.classList.remove("hidden");
|
|
}
|
|
|
|
export function updateLoopRegionVisual() {
|
|
const regionItem = document.getElementById("t-export-region");
|
|
const hasRegion = loopEnabled && totalDuration > 0 && loopEnd > loopStart;
|
|
if (regionItem) regionItem.setAttribute("aria-disabled", String(!hasRegion));
|
|
// Keep the engine's loop bounds in sync with every loop change (toggle/drag);
|
|
// the engine wraps playback itself off these values. No-op on streaming path.
|
|
audioEngine?.setLoop(loopEnabled, loopStart, loopEnd);
|
|
// Mirror the bounds into the exact-loop text fields (skips fields being edited).
|
|
syncLoopInputs();
|
|
if (!loopEnabled || !totalDuration) {
|
|
loopRegionEl.classList.add("hidden");
|
|
return;
|
|
}
|
|
ensureLoopRegionParent();
|
|
// Keep the loop overlay in the same normalized timeline coordinate
|
|
// system as the ruler ticks. Percentages avoid CSS zoom mismatch:
|
|
// pointer coordinates and getBoundingClientRect() are visual pixels,
|
|
// but style.left/style.width in px are unzoomed layout pixels.
|
|
const startPct = Math.max(0, Math.min(100, (loopStart / totalDuration) * 100));
|
|
const endPct = Math.max(0, Math.min(100, (loopEnd / totalDuration) * 100));
|
|
loopRegionEl.style.left = `${startPct}%`;
|
|
loopRegionEl.style.width = `${Math.max(0, endPct - startPct)}%`;
|
|
loopRegionEl.classList.remove("hidden");
|
|
}
|
|
|
|
// Keep the exact-loop text fields in sync with loopStart/loopEnd after any
|
|
// 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;
|
|
for (const [input, value] of [
|
|
[loopStartInput, loopStart],
|
|
[loopEndInput, loopEnd],
|
|
]) {
|
|
if (!input) continue;
|
|
input.disabled = !enabled;
|
|
if (document.activeElement !== input) input.value = fmtTimeMs(value);
|
|
}
|
|
}
|
|
|
|
// Commit a typed loop time. Invalid/out-of-range input reverts the field to the
|
|
// current stored value (self-evident rejection) rather than raising an error;
|
|
// showError lives in the import form and would surface in the wrong place.
|
|
function commitLoopInput(which) {
|
|
const input = which === "start" ? loopStartInput : loopEndInput;
|
|
if (!input) return;
|
|
const revert = () => {
|
|
input.value = fmtTimeMs(which === "start" ? loopStart : loopEnd);
|
|
};
|
|
const parsed = parseTimecode(input.value);
|
|
if (parsed === null || totalDuration <= 0) {
|
|
revert();
|
|
return;
|
|
}
|
|
const v = Math.max(0, Math.min(totalDuration, parsed));
|
|
const start = which === "start" ? v : loopStart;
|
|
const end = which === "end" ? v : loopEnd;
|
|
if (end - start < MIN_LOOP_SEC) {
|
|
revert();
|
|
return;
|
|
}
|
|
setLoopStart(start);
|
|
setLoopEnd(end);
|
|
setLoopEnabled(true);
|
|
loopBtn.classList.add("active");
|
|
updateLoopRegionVisual();
|
|
}
|
|
|
|
function wireLoopInputs() {
|
|
for (const [input, which] of [
|
|
[loopStartInput, "start"],
|
|
[loopEndInput, "end"],
|
|
]) {
|
|
if (!input) continue;
|
|
input.addEventListener("blur", () => commitLoopInput(which));
|
|
input.addEventListener("keydown", (e) => {
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
input.blur();
|
|
} else if (e.key === "Escape") {
|
|
e.preventDefault();
|
|
input.value = fmtTimeMs(which === "start" ? loopStart : loopEnd);
|
|
input.blur();
|
|
}
|
|
});
|
|
}
|
|
syncLoopInputs();
|
|
}
|
|
|
|
// Standard DAW transport state machine:
|
|
// [stopped] (paused at start) ─Play→ [playing]
|
|
// ↑ ↓ Play
|
|
// Stop [paused] (paused mid-track)
|
|
// Stop ↓
|
|
// [stopped]
|
|
//
|
|
// Play button is a Play/Pause toggle. Stop both pauses and returns the
|
|
// playhead to 0 (or loopStart if loop is on). Visual state is driven
|
|
// from the multitrack lifecycle events in player.js (mt.on play/pause/
|
|
// timeupdate) — click handlers only mutate the transport, never the
|
|
// button's CSS class. That way manual seeks (e.g. clicking the ruler)
|
|
// keep the button states in sync without extra plumbing.
|
|
// WKWebView (Tauri desktop) has small audio buffers. After a seek, all
|
|
// audio elements drop their buffers and issue new range requests simultaneously.
|
|
// Calling play() before they reach HAVE_FUTURE_DATA (readyState >= 3) causes
|
|
// choppiness. Wait for all elements to be ready, with a hard 1.5 s fallback so
|
|
// the user is never stuck. Desktop browsers buffer aggressively enough that
|
|
// this wait is skipped entirely (readyState is already >= 3 by the time play
|
|
// is pressed after a seek).
|
|
function _playWhenReady() {
|
|
if (!multitrack) return;
|
|
const inTauri = Boolean(window.__TAURI__?.core?.invoke);
|
|
if (!inTauri) { multitrack.play(); return; }
|
|
|
|
const audios = (multitrack.audios ?? [])
|
|
.filter((a) => a instanceof HTMLMediaElement && a.src);
|
|
const notReady = audios.filter((a) => a.readyState < 3);
|
|
if (!notReady.length) { multitrack.play(); return; }
|
|
|
|
let fired = false;
|
|
const fire = () => { if (!fired && multitrack && !multitrack.isPlaying()) { fired = true; multitrack.play(); } };
|
|
const waits = notReady.map((a) => new Promise((res) => {
|
|
if (a.readyState >= 3) { res(); return; }
|
|
const onReady = () => { a.removeEventListener("canplay", onReady); res(); };
|
|
a.addEventListener("canplay", onReady);
|
|
}));
|
|
Promise.all(waits).then(fire);
|
|
window.setTimeout(fire, 1500);
|
|
}
|
|
|
|
export function togglePlayPause() {
|
|
const eng = audioEngine;
|
|
const tx = eng ?? multitrack;
|
|
if (!tx) return;
|
|
if (tx.isPlaying()) {
|
|
tx.pause();
|
|
// The engine emits no play/pause events (the multitrack stays silent), so
|
|
// the play-button visual that the ws "pause" handler normally toggles must
|
|
// be driven here directly.
|
|
if (eng) playBtn.classList.remove("playing");
|
|
return;
|
|
}
|
|
const ctx = tx.audioContext;
|
|
// Safari requires play() to be called synchronously within the user-gesture
|
|
// handler. Resume the AudioContext fire-and-forget so the context becomes
|
|
// live, then call play() immediately on the same tick.
|
|
if (ctx && ctx.state === "suspended") {
|
|
ctx.resume().catch(() => {});
|
|
}
|
|
// Snap playhead to loopStart on play (DAW convention).
|
|
if (loopEnabled && totalDuration > 0) {
|
|
tx.setTime(loopStart);
|
|
}
|
|
if (eng) {
|
|
eng.play();
|
|
playBtn.classList.add("playing");
|
|
stopBtn.classList.remove("stopped");
|
|
} else {
|
|
_playWhenReady();
|
|
}
|
|
}
|
|
|
|
export function stopTransport() {
|
|
const eng = audioEngine;
|
|
const tx = eng ?? multitrack;
|
|
if (!tx) return;
|
|
tx.pause();
|
|
tx.setTime(loopEnabled ? loopStart : 0); // engine: setTime → onTime → stop visual
|
|
if (eng) playBtn.classList.remove("playing");
|
|
}
|
|
|
|
export function toggleLoop() {
|
|
setLoopEnabled(!loopEnabled);
|
|
loopBtn.classList.toggle("active", loopEnabled);
|
|
updateLoopRegionVisual();
|
|
}
|
|
|
|
// Click-drag on the timeline ruler or waveform body to define the loop
|
|
// region. Drag direction doesn't matter -- start and end get sorted.
|
|
// Tiny drags are treated as clicks and seek the playhead instead.
|
|
function wireLoopDrag() {
|
|
let dragging = false;
|
|
let dragStartTime = 0;
|
|
let activePointerId = null;
|
|
let moved = false;
|
|
|
|
const startDrag = (e, surface) => {
|
|
if (e.button !== 0 || e.target.closest(".loop-region")) return;
|
|
const t = timeFromClientX(e.clientX);
|
|
if (t === null) return;
|
|
dragging = true;
|
|
activePointerId = e.pointerId;
|
|
moved = false;
|
|
dragStartTime = t;
|
|
setLoopStart(t);
|
|
setLoopEnd(t);
|
|
setLoopEnabled(true);
|
|
loopBtn.classList.add("active");
|
|
updateLoopRegionVisual();
|
|
surface.setPointerCapture(e.pointerId);
|
|
e.preventDefault();
|
|
};
|
|
|
|
const moveDrag = (e) => {
|
|
if (!dragging || e.pointerId !== activePointerId) return;
|
|
const t = timeFromClientX(e.clientX);
|
|
if (t === null) return;
|
|
if (Math.abs(t - dragStartTime) >= MIN_LOOP_SEC) moved = true;
|
|
if (t < dragStartTime) {
|
|
setLoopStart(t);
|
|
setLoopEnd(dragStartTime);
|
|
} else {
|
|
setLoopStart(dragStartTime);
|
|
setLoopEnd(t);
|
|
}
|
|
updateLoopRegionVisual();
|
|
};
|
|
|
|
const finishDrag = (e) => {
|
|
if (!dragging || e.pointerId !== activePointerId) return;
|
|
dragging = false;
|
|
activePointerId = null;
|
|
const clicked = !moved || loopEnd - loopStart < MIN_LOOP_SEC;
|
|
if (clicked) {
|
|
setLoopEnabled(false);
|
|
loopBtn.classList.remove("active");
|
|
updateLoopRegionVisual();
|
|
setPlayheadTime(dragStartTime);
|
|
}
|
|
};
|
|
|
|
const wavesColumn = document.querySelector(".waves-column");
|
|
const surfaces = [rulerTime, wavesColumn].filter(Boolean);
|
|
for (const surface of surfaces) {
|
|
surface.addEventListener("pointerdown", (e) => {
|
|
if (surface === rulerTime && e.target !== rulerTime) return;
|
|
startDrag(e, surface);
|
|
});
|
|
surface.addEventListener("pointermove", moveDrag);
|
|
surface.addEventListener("pointerup", finishDrag);
|
|
surface.addEventListener("pointercancel", finishDrag);
|
|
}
|
|
}
|
|
|
|
// ─── Zoom ───
|
|
//
|
|
// Single CSS variable `--zoom` on .wave-canvas drives the visual width
|
|
// (canvas = 100% * zoom). Multitrack's pxPerSec is set to match so its
|
|
// internal canvases stay the exact same pixel width as the canvas; that
|
|
// way the bundle never adds its own internal horizontal scroll, which
|
|
// historically broke alignment with our ruler/loop overlay.
|
|
//
|
|
// All percentage-positioned children (ruler ticks, playhead, grid lines,
|
|
// loop region) automatically stretch with the canvas, so the loop drag
|
|
// math stays correct without any per-element width logic.
|
|
|
|
// Keep the header ruler horizontally aligned with the (possibly wider, scrolled)
|
|
// waveform body. The ruler lives outside .wave-scroll, so translate it by the
|
|
// same scrollLeft; .daw-ruler-area uses overflow-x: clip to hide the spill while
|
|
// leaving the vertical playhead line (overflow-y: visible) intact.
|
|
export function syncRulerScroll() {
|
|
if (rulerTime && waveScroll) {
|
|
rulerTime.style.transform = `translateX(${-waveScroll.scrollLeft}px)`;
|
|
}
|
|
}
|
|
|
|
export function applyWaveZoom() {
|
|
const lanes = document.getElementById("lanes") || waveCanvas;
|
|
const wavesColumn = document.querySelector(".waves-column");
|
|
if (wavesColumn) {
|
|
lanes?.style.setProperty("--wave-playhead-h", `${wavesColumn.clientHeight}px`);
|
|
}
|
|
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);
|
|
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.
|
|
// Measuring post-reflow avoids the resize race where WaveSurfer renders
|
|
// wider than its container and exposes its own (unstyled, light) internal
|
|
// horizontal scrollbar — the only horizontal scroll must come from the
|
|
// outer .wave-scroll, which also keeps the ruler/playhead aligned.
|
|
lanes?.style.setProperty("--zoom", String(zoom));
|
|
requestAnimationFrame(() => {
|
|
if (!multitrack || totalDuration <= 0) return;
|
|
const w = multitrackContainer?.clientWidth || contentWidth;
|
|
try { multitrack.zoom(w / totalDuration); } catch { /* ignore -- pre-canplay */ }
|
|
syncRulerScroll();
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
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(); });
|
|
});
|
|
ro.observe(waveScroll);
|
|
}
|
|
if (waveScroll) {
|
|
waveScroll.addEventListener("wheel", (e) => {
|
|
if (waveScroll.scrollWidth <= waveScroll.clientWidth) return;
|
|
if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
|
|
e.preventDefault();
|
|
waveScroll.scrollLeft += e.deltaY;
|
|
}
|
|
}, { passive: false });
|
|
waveScroll.addEventListener("scroll", syncRulerScroll, { passive: true });
|
|
}
|
|
applyWaveZoom();
|
|
}
|
|
|
|
// Keep the mixer column and the waveform area scrolled in lockstep so stem
|
|
// controls stay aligned with their lanes when the stack overflows (#159).
|
|
function wireLaneScrollSync() {
|
|
const mixer = document.getElementById("mixer");
|
|
if (!mixer || !waveScroll) return;
|
|
// Mirror scrollTop between the two panes by assigning only when the values
|
|
// differ. The echo stops on its own (once equal, the partner's handler is a
|
|
// no-op), so no reentrancy guard / rAF is needed — that frame-delayed guard
|
|
// was what made inertial scrolling stutter (#163).
|
|
const link = (src, dst) =>
|
|
src.addEventListener("scroll", () => {
|
|
if (dst.scrollTop !== src.scrollTop) dst.scrollTop = src.scrollTop;
|
|
}, { passive: true });
|
|
link(mixer, waveScroll);
|
|
link(waveScroll, mixer);
|
|
}
|
|
|
|
// ─── Wire transport buttons ───
|
|
|
|
export function wireTransportButtons() {
|
|
playBtn.addEventListener("click", togglePlayPause);
|
|
playMiniBtn?.addEventListener("click", togglePlayPause);
|
|
stopBtn.addEventListener("click", stopTransport);
|
|
loopBtn.addEventListener("click", toggleLoop);
|
|
wireLoopDrag();
|
|
wireLoopInputs();
|
|
wireZoomButtons();
|
|
wireLaneScrollSync();
|
|
masterFader?.addEventListener("input", () => {
|
|
setMasterVolume(parseFloat(masterFader.value));
|
|
applyMix();
|
|
});
|
|
masterFader?.addEventListener("dblclick", () => {
|
|
masterFader.value = "0.5";
|
|
setMasterVolume(0.5);
|
|
applyMix();
|
|
});
|
|
wireSpeedControl();
|
|
}
|
|
|
|
function applySpeed(rate) {
|
|
const clamped = Math.max(0.25, Math.min(2, rate));
|
|
setPlaybackSpeed(clamped);
|
|
if (speedEl) {
|
|
speedEl.value = String(clamped);
|
|
// range is 0-2; 1.0 sits at exactly 50%
|
|
const pct = (clamped / 2) * 100;
|
|
speedEl.style.setProperty("--speed-pct", `${pct.toFixed(1)}%`);
|
|
}
|
|
if (speedLabelEl) speedLabelEl.textContent = `${clamped % 1 === 0 ? clamped.toFixed(1) : clamped}x`;
|
|
audioEngine?.setPlaybackRate?.(clamped);
|
|
if (multitrack) {
|
|
for (const a of (multitrack.audios ?? [])) {
|
|
try { a.playbackRate = clamped; } catch { /* noop */ }
|
|
}
|
|
}
|
|
}
|
|
|
|
export function resetSpeed() {
|
|
applySpeed(1.0);
|
|
}
|
|
|
|
function wireSpeedControl() {
|
|
if (!speedEl) return;
|
|
speedEl.addEventListener("input", () => applySpeed(parseFloat(speedEl.value)));
|
|
speedEl.addEventListener("dblclick", () => applySpeed(1.0));
|
|
speedEl.addEventListener("wheel", (e) => {
|
|
e.preventDefault();
|
|
const delta = e.deltaY < 0 ? 0.25 : -0.25;
|
|
applySpeed(parseFloat(speedEl.value) + delta);
|
|
}, { passive: false });
|
|
}
|