diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 435fc62..6287137 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,6 +75,13 @@ jobs: # local-only, single-user app is negligible. Re-evaluate once demucs # supports torch 2.7+ without torchcodec. # + # torch PYSEC-2026-2286 (torch.load weights_only deserialization -> ACE, + # HIGH; fix 2.10.0) -- the exploit needs an attacker-controlled .pth. + # StemDeck never calls torch.load on untrusted input: demucs loads only its + # official model weights from the trusted torch-hub source, and users + # submit audio, not checkpoints. The same <2.7 pin blocks the 2.10.0 fix; + # re-evaluate with the torch upgrade noted above. + # # joblib PYSEC-2024-277 -- no fix version available as of 2026-05-21 # (1.5.3 is latest). joblib is a transitive dep via demucs/librosa; # StemDeck does not directly invoke joblib serialization. Drop once @@ -105,6 +112,7 @@ jobs: --ignore-vuln PYSEC-2025-209 \ --ignore-vuln PYSEC-2025-210 \ --ignore-vuln PYSEC-2026-139 \ + --ignore-vuln PYSEC-2026-2286 \ --ignore-vuln PYSEC-2024-277 \ --ignore-vuln CVE-2025-2148 \ --ignore-vuln CVE-2025-2149 \ diff --git a/static/js/chunkedAudioEngine.js b/static/js/chunkedAudioEngine.js index 7ae4b51..acfd3db 100644 --- a/static/js/chunkedAudioEngine.js +++ b/static/js/chunkedAudioEngine.js @@ -132,13 +132,18 @@ export function createChunkedAudioEngine(stems, { onTime, onEnded, context } = { }) : Promise.resolve().then(() => { master.connect(ctx.destination); })); - // Per-stem state: url, parsed WAV header, gain node, currently playing nodes + // Per-stem state: url, parsed WAV header, gain node, analyser (VU tap), and + // currently playing nodes. Graph per stem: sources -> gain -> analyser -> master. + // The analyser sits post-gain so VU meters reflect volume/mute/solo. const stemMap = new Map(); for (const s of stems) { if (!s?.url) continue; const gain = ctx.createGain(); - gain.connect(master); - stemMap.set(s.name, { url: s.url, header: null, gain, activeNodes: [] }); + const analyser = ctx.createAnalyser(); + analyser.fftSize = 1024; + gain.connect(analyser); + analyser.connect(master); + stemMap.set(s.name, { url: s.url, header: null, gain, analyser, activeNodes: [] }); } let _duration = 0; @@ -156,6 +161,11 @@ export function createChunkedAudioEngine(stems, { onTime, onEnded, context } = { // getCurrentTime() from advancing during an async chunk fetch. let _audioStarted = false; let _filling = false; // prevents concurrent _scheduleNext() calls + let loop = { enabled: false, start: 0, end: 0 }; + // Chunk index that must survive cache eviction while looping (the loop-start + // chunk), so every pass around the loop replays from cache with no refetch. + const _loopPinChunk = () => + (loop.enabled && loop.end > loop.start) ? Math.floor(loop.start / CHUNK_SEC) : -1; // Chunk cache: chunkIdx -> { promise: Promise, result: Map|null } // result is set synchronously once the promise resolves so play() can @@ -212,9 +222,16 @@ export function createChunkedAudioEngine(stems, { onTime, onEnded, context } = { const map = new Map(pairs.filter(([, b]) => b)); entry.result = map; // Keep at most the previous chunk + current in cache to bound memory. + // The loop-start chunk is pinned so loop passes replay from cache. + const pin = _loopPinChunk(); for (const k of _cache.keys()) { - if (k < chunkIdx - 1) _cache.delete(k); + if (k < chunkIdx - 1 && k !== pin) _cache.delete(k); } + // An all-stems-empty result means every fetch failed (e.g. a transient + // network drop) — drop the entry so a later scheduler pass retries + // instead of caching permanent silence. Past-EOF chunks are never + // re-requested: _maybeSchedule stops at duration. + if (map.size === 0) _cache.delete(chunkIdx); return map; })(); @@ -283,7 +300,10 @@ export function createChunkedAudioEngine(stems, { onTime, onEnded, context } = { function _maybeSchedule() { if (_filling || !playing || destroyed) return; - if (_scheduledTo >= _duration) return; + // While looping, don't schedule past loop.end — _tick jumps back to + // loop.start when the playhead crosses it (bounded by one rAF frame). + const limit = (loop.enabled && loop.end > loop.start) ? loop.end : _duration; + if (_scheduledTo >= limit) return; if (_scheduledTo - _getCurrentTime() < LOOKAHEAD_SEC) { _filling = true; _scheduleNext().finally(() => { _filling = false; }); @@ -293,6 +313,16 @@ export function createChunkedAudioEngine(stems, { onTime, onEnded, context } = { function _tick() { if (!playing) return; const t = _getCurrentTime(); + if (loop.enabled && loop.end > loop.start) { + // Warm the loop-start chunk while approaching the end so the jump back + // schedules from cache instead of paying a fetch (deduped by _cache and + // pinned against eviction while the loop stays active). + if (loop.end - t < LOOKAHEAD_SEC) _fetchChunk(_loopPinChunk()); + if (t >= loop.end) { + seek(loop.start); // re-arms playback + tick from the loop start + return; + } + } if (t >= _duration) { playing = false; _audioStarted = false; @@ -317,9 +347,12 @@ export function createChunkedAudioEngine(stems, { onTime, onEnded, context } = { const chunkIdx = Math.floor(_startOffset / CHUNK_SEC); const offsetWithin = _startOffset - chunkIdx * CHUNK_SEC; - const startWith = (buffers) => { + // `lead` = scheduling safety margin. The cached (sync) path uses 10 ms — + // tight enough that loop jumps are near-seamless — while the async path + // keeps 50 ms headroom since a fetch/decode just finished. + const startWith = (buffers, lead) => { if (!playing || destroyed) return; - const when = ctx.currentTime + 0.05; + const when = ctx.currentTime + lead; _startCtxTime = when; const dur = _scheduleChunk(buffers, when, offsetWithin); _scheduledTo = _startOffset + dur; @@ -331,10 +364,10 @@ export function createChunkedAudioEngine(stems, { onTime, onEnded, context } = { // chunk 0 is pre-decoded during ready(), so the sync path is the hot path. const hit = _cache.get(chunkIdx); if (hit?.result) { - startWith(hit.result); + startWith(hit.result, 0.01); } else { _fetchChunk(chunkIdx) - .then(startWith) + .then((buffers) => startWith(buffers, 0.05)) .catch((e) => { console.warn("[chunked] play fetch failed:", e); playing = false; @@ -364,10 +397,12 @@ export function createChunkedAudioEngine(stems, { onTime, onEnded, context } = { } _startOffset = clamped; _scheduledTo = clamped; - // Evict cache for chunks before the new position. + // Evict cache for chunks before the new position (except the pinned + // loop-start chunk — a loop jump seeks backward *to* that chunk). const newIdx = Math.floor(clamped / CHUNK_SEC); + const pin = _loopPinChunk(); for (const k of _cache.keys()) { - if (k < newIdx) _cache.delete(k); + if (k < newIdx && k !== pin) _cache.delete(k); } onTime?.(clamped); if (wasPlaying) play(); @@ -408,7 +443,7 @@ export function createChunkedAudioEngine(stems, { onTime, onEnded, context } = { isPlaying: () => playing, getCurrentTime: _getCurrentTime, getDuration: () => _duration, - setLoop: () => {}, + setLoop: (enabled, start, end) => { loop = { enabled, start, end }; }, setGain(name, v) { const stem = stemMap.get(name); if (stem) stem.gain.gain.setTargetAtTime(Math.max(0, v), ctx.currentTime, 0.01); @@ -427,7 +462,7 @@ export function createChunkedAudioEngine(stems, { onTime, onEnded, context } = { seek(t); } }, - getAnalyser: () => null, + getAnalyser: (name) => stemMap.get(name)?.analyser ?? null, getBuffers: () => new Map(), destroy() { destroyed = true; diff --git a/static/js/mixer.js b/static/js/mixer.js index c12f376..f3b373f 100644 --- a/static/js/mixer.js +++ b/static/js/mixer.js @@ -241,6 +241,41 @@ export function renderRealMiniWave(stemName, audioBuffer, color) { } } +// Peaks-based mini-wave for the streaming/chunked engine path, where no full +// decoded buffer is available. `pts` is the backend peaks.json array for the +// stem: [[min,max], ...] (1500 points). Mirrors renderRealMiniWave's bar layout. +export function renderRealMiniWaveFromPeaks(stemName, pts, color) { + const svg = mixerEl.querySelector(`.lane-mini-wave[data-stem="${stemName}"]`); + if (!svg || !pts?.length) return; + const binSize = Math.max(1, Math.floor(pts.length / MINI_WAVE_BARS)); + const peaks = new Array(MINI_WAVE_BARS); + let max = 0; + for (let i = 0; i < MINI_WAVE_BARS; i++) { + const start = i * binSize; + const end = i === MINI_WAVE_BARS - 1 ? pts.length : start + binSize; + let p = 0; + for (let j = start; j < end; j++) { + const v = Math.max(Math.abs(pts[j][0]), Math.abs(pts[j][1])); + if (v > p) p = v; + } + peaks[i] = p; + if (p > max) max = p; + } + const norm = max > 0 ? 1 / max : 0; + while (svg.firstChild) svg.removeChild(svg.firstChild); + for (let i = 0; i < MINI_WAVE_BARS; i++) { + const h = Math.max(1.5, peaks[i] * norm * MINI_WAVE_VIEWBOX_H); + const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect"); + rect.setAttribute("x", `${i * 2}`); + rect.setAttribute("y", `${(MINI_WAVE_VIEWBOX_H - h) / 2}`); + rect.setAttribute("width", "1"); + rect.setAttribute("height", `${h}`); + rect.setAttribute("fill", color); + rect.setAttribute("opacity", "0.95"); + svg.appendChild(rect); + } +} + function downloadIcon() { const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.setAttribute("viewBox", "0 0 24 24"); diff --git a/static/js/player.js b/static/js/player.js index 4407ee1..3ad0aa2 100644 --- a/static/js/player.js +++ b/static/js/player.js @@ -19,10 +19,11 @@ import { setFooterWaveDrawFn, } from "./state.js"; import { createAudioEngine, estimateDecodedBytes } from "./audioEngine.js"; +import { createChunkedAudioEngine } from "./chunkedAudioEngine.js"; import { loadMixIntoState, resetMixerState, refreshMixerVisuals, setLaneControlsEnabled, ensureMixerStateDefaults, applyMix, - renderRealMiniWave, renderMixerRow, + renderRealMiniWave, renderRealMiniWaveFromPeaks, renderMixerRow, } from "./mixer.js"; import { buildRuler, updatePlayheadMarker, updateLoopRegionVisual, @@ -32,32 +33,34 @@ import { import { stopVuLoop } from "./audio.js"; import { destroySections } from "./sections.js"; -// Feature flag for the Web Audio decode-and-mix engine (audioEngine.js). -// Playback runs off decoded AudioBuffers on a single AudioContext clock instead -// of N streaming