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
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 \
Expand Down
61 changes: 48 additions & 13 deletions static/js/chunkedAudioEngine.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<Map>, result: Map|null }
// result is set synchronously once the promise resolves so play() can
Expand Down Expand Up @@ -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;
})();

Expand Down Expand Up @@ -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; });
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand Down
35 changes: 35 additions & 0 deletions static/js/mixer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading