From cb02b940a13dcde736cb1428164e6893992f49a9 Mon Sep 17 00:00:00 2001 From: Thales Pereira <31625914+thcp@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:48:05 +0100 Subject: [PATCH 1/3] fix(player): read past the first 1 KB when locating the WAV data chunk The chunked engine parses WAV containers itself and asked only for bytes 0-1023 when looking for the `data` chunk. RIFF is a linked list, so anything the writer puts in front of `data` -- a LIST/INFO block, a JUNK chunk padded for sector alignment -- pushes it out of that window. The parser returned null, the engine reported a duration of 0, and playback was disabled. The track rendered normally and the header showed its real length, so it looked like a GPU or renderer fault rather than a parse failure (#343). Walk the chunk table properly and widen the request when it runs past what was fetched, capped at 1 MB and 5 attempts. The parser reports "need more bytes" separately from "not a WAV", since only the caller knows whether more bytes can be had. Two further container cases fixed along the way: - WAVE_FORMAT_EXTENSIBLE carries the real format code in its SubFormat GUID. Without reading it, a float32 file parsed cleanly and then decoded to silence. - A `data` size of 0 or 0xffffffff, written by encoders that stream to a non-seekable target and never patch the length, gave a duration of 0 or 24347 seconds respectively. Clamp to the real length reported in Content-Range. Adds tests/js/wav-header.test.mjs, which drives the real engine against synthetic layouts through a Range-honouring fetch stub. Against the pre-fix engine it reports 25/38, with JUNK 4096, LIST 2 KB, JUNK 300 KB and both unpatched data sizes failing. CI runs it alongside node --check. Closes #358 --- .github/workflows/ci.yml | 1 + static/js/chunkedAudioEngine.js | 159 +++++++++++++++++++++---- tests/js/wav-header.test.mjs | 198 ++++++++++++++++++++++++++++++++ 3 files changed, 337 insertions(+), 21 deletions(-) create mode 100644 tests/js/wav-header.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 22740f3..922cdf0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,7 @@ jobs: steps: - uses: actions/checkout@v7.0.1 - run: for f in static/js/*.js; do node --check "$f"; done + - run: for f in tests/js/*.test.mjs; do node "$f"; done sast-bandit: runs-on: ubuntu-latest diff --git a/static/js/chunkedAudioEngine.js b/static/js/chunkedAudioEngine.js index 9fd99fc..6281531 100644 --- a/static/js/chunkedAudioEngine.js +++ b/static/js/chunkedAudioEngine.js @@ -18,43 +18,100 @@ const CHUNK_SEC = 5; // seconds of audio per chunk const LOOKAHEAD_SEC = 12; // schedule next chunk this far ahead of playhead +// First probe covers the common case: a 44-byte canonical header, or one with a +// modest LIST/INFO block. Anything larger costs a second round trip rather than +// silently failing. +const HEADER_PROBE_BYTES = 1024; +// Chase the chunk table this far before declaring the file unreadable. Writers +// pad with JUNK for sector alignment (commonly 4 KB) or embed cover art, but a +// file that has not declared `data` within 1 MB is not one we can stream. +const HEADER_MAX_BYTES = 1 << 20; +const HEADER_MAX_ATTEMPTS = 5; + // --------------------------------------------------------------------------- // WAV parsing // --------------------------------------------------------------------------- -function _parseWavHeader(buf) { +/** + * Walk the RIFF chunk table looking for `fmt ` and `data`. + * + * The table is a linked list, so `data` can sit behind any amount of metadata: + * a LIST/INFO block, or a JUNK chunk written for sector alignment. Parsing a + * fixed prefix and giving up is what disabled playback outright on files whose + * writer emitted more than the usual 44 bytes (#358), so running off the end of + * the buffer is reported as "need more bytes" and not as a parse failure. Only + * the caller knows whether more bytes can be had. + * + * @param {ArrayBuffer} buf A prefix of the file, starting at byte 0. + * @param {number} fileSize Total file length if known, else 0. + * @returns {{header:object}|{needBytes:number}|{invalid:true}} + */ +function _parseWavHeader(buf, fileSize = 0) { const view = new DataView(buf); const tag = (off) => String.fromCharCode(...new Uint8Array(buf, off, 4)); - if (tag(0) !== "RIFF" || tag(8) !== "WAVE") return null; + if (buf.byteLength < 12) return { needBytes: 12 }; + if (tag(0) !== "RIFF" || tag(8) !== "WAVE") return { invalid: true }; let audioFormat = 1, channels = 2, sampleRate = 44100, bitsPerSample = 16; let dataOffset = -1, dataSize = 0; + let sawFmt = false; let off = 12; - while (off + 8 <= buf.byteLength) { + for (;;) { + if (off + 8 > buf.byteLength) return { needBytes: off + 8 }; const id = tag(off); const size = view.getUint32(off + 4, true); + + if (id === "data") { + dataOffset = off + 8; + dataSize = size; + break; + } + if (id === "fmt ") { + if (off + 24 > buf.byteLength) return { needBytes: off + 24 }; audioFormat = view.getUint16(off + 8, true); channels = view.getUint16(off + 10, true); sampleRate = view.getUint32(off + 12, true); bitsPerSample = view.getUint16(off + 22, true); - } else if (id === "data") { - dataOffset = off + 8; - dataSize = size; - break; + // WAVE_FORMAT_EXTENSIBLE keeps the real format code in the first field of + // the SubFormat GUID. Without reading it, a float32 extensible file parses + // cleanly and then decodes to silence, because _pcmToAudioBuffer only + // recognises 1 (PCM) and 3 (float). + if (audioFormat === 0xfffe && size >= 40) { + if (off + 34 > buf.byteLength) return { needBytes: off + 34 }; + audioFormat = view.getUint16(off + 32, true); + } + sawFmt = true; } - off += 8 + size + (size & 1); // chunks are word-aligned - } - if (dataOffset < 0) return null; + const next = off + 8 + size + (size & 1); // chunks are word-aligned + // A chunk that fails to advance, or that claims to run past the end of the + // file, means the table is corrupt. Without this the caller's widening loop + // would keep asking for bytes that will never resolve anything. + if (next <= off) return { invalid: true }; + if (fileSize && next > fileSize) return { invalid: true }; + off = next; + } + if (!sawFmt || !channels || !sampleRate || !bitsPerSample) return { invalid: true }; const bytesPerFrame = channels * (bitsPerSample >> 3); + if (!bytesPerFrame) return { invalid: true }; + + // `data` may declare a size the file does not actually have: 0 and 0xffffffff + // are both used by writers that stream to a non-seekable target and never go + // back to patch the length. Either would yield a nonsense duration, and a + // duration of 0 reads downstream as "no usable audio". Trust the file length. + const available = fileSize ? Math.max(0, fileSize - dataOffset) : 0; + if (available && (dataSize === 0 || dataSize > available)) dataSize = available; + return { - audioFormat, channels, sampleRate, bitsPerSample, - dataOffset, dataSize, bytesPerFrame, - duration: dataSize / (bytesPerFrame * sampleRate), + header: { + audioFormat, channels, sampleRate, bitsPerSample, + dataOffset, dataSize, bytesPerFrame, + duration: dataSize / (bytesPerFrame * sampleRate), + }, }; } @@ -150,6 +207,9 @@ export function createChunkedAudioEngine(stems, { onTime, onEnded, context } = { let playing = false; let destroyed = false; let rafId = null; + // Why ready() resolved false, in words fit to show a user. Read via + // getLoadError() by the caller that decides what to put on screen. + let _loadError = null; // Playback clock: getCurrentTime = ctx.currentTime - _startCtxTime + _startOffset let _startCtxTime = 0; @@ -199,10 +259,42 @@ export function createChunkedAudioEngine(stems, { onTime, onEnded, context } = { // --- fetch helpers --- + // Read enough of the file to locate the `data` chunk, widening the request + // when the chunk table runs past what we asked for. Returns null when the file + // is not readable as a WAV, which the caller reports rather than swallows. async function _fetchHeader(url) { - const res = await fetch(url, { headers: { Range: "bytes=0-1023" } }); - const buf = await res.arrayBuffer(); - return _parseWavHeader(buf); + let want = HEADER_PROBE_BYTES; + let fileSize = 0; + + for (let attempt = 0; attempt < HEADER_MAX_ATTEMPTS; attempt++) { + const res = await fetch(url, { headers: { Range: `bytes=0-${want - 1}` } }); + if (!res.ok && res.status !== 206) throw new Error(`header fetch ${res.status}`); + + // "bytes 0-1023/5242880" gives us the real length without a second request. + const total = Number(/\/(\d+)\s*$/.exec(res.headers.get("Content-Range") || "")?.[1]); + if (Number.isFinite(total) && total > 0) fileSize = total; + + const buf = await res.arrayBuffer(); + const out = _parseWavHeader(buf, fileSize); + if (out.header) return out.header; + if (out.invalid) return null; + + // A 200 means the server ignored Range and already sent the whole file, so + // asking for a wider window cannot produce anything new. + if (res.status === 200 || (fileSize && buf.byteLength >= fileSize)) return null; + + // Grow past what the table says it needs, geometrically, so a file with + // several metadata chunks converges in a couple of round trips instead of + // one per chunk. + const next = Math.min( + Math.max(out.needBytes, buf.byteLength * 4), + HEADER_MAX_BYTES, + fileSize || HEADER_MAX_BYTES, + ); + if (next <= buf.byteLength) return null; // cannot grow; give up + want = next; + } + return null; } async function _fetchPcm(stem, chunkIdx) { @@ -436,20 +528,44 @@ export function createChunkedAudioEngine(stems, { onTime, onEnded, context } = { // only, ~6 x 1 KB) instead of blocking on the full first-chunk download (~5 MB). // play() handles the case where chunk 0 is not yet cached. const ready = (async () => { - if (!stemMap.size) return false; + if (!stemMap.size) { + _loadError = "This track has no stem files to play."; + return false; + } + + // Counted so the failure can name a cause. "Could not download" and "could + // not read" send the user somewhere completely different, and until #359 + // both arrived as the same silent console warning. + let unreachable = 0; + let unreadable = 0; await Promise.all([ _workletReady, - ...[...stemMap.values()].map(async (stem) => { - try { stem.header = await _fetchHeader(stem.url); } - catch (e) { console.warn("[chunked] header fetch failed:", e); } + ...[...stemMap.entries()].map(async ([name, stem]) => { + try { + stem.header = await _fetchHeader(stem.url); + if (!stem.header) { + unreadable++; + console.warn(`[chunked] unreadable WAV header for stem "${name}"`); + } + } catch (e) { + unreachable++; + console.warn(`[chunked] header fetch failed for stem "${name}":`, e); + } }), ]); for (const stem of stemMap.values()) { if (stem.header) _duration = Math.max(_duration, stem.header.duration); } - if (!_duration) return false; + if (!_duration) { + _loadError = unreadable + ? "This track's audio files are in a format StemDeck could not read." + : unreachable + ? "Could not load this track's audio files." + : "This track's audio files contain no audio."; + return false; + } // Kick off chunk 0 and 1 in the background; play() picks up the cached result. _fetchChunk(0); @@ -459,6 +575,7 @@ export function createChunkedAudioEngine(stems, { onTime, onEnded, context } = { return { ready, + getLoadError: () => _loadError, play, pause, seek, diff --git a/tests/js/wav-header.test.mjs b/tests/js/wav-header.test.mjs new file mode 100644 index 0000000..3bfb1c5 --- /dev/null +++ b/tests/js/wav-header.test.mjs @@ -0,0 +1,198 @@ +// Regression test for #358: the chunked engine used to read only the first 1 KB +// of a WAV looking for the `data` chunk, so any file whose writer put more +// metadata in front of it loaded with duration 0 and playback silently off. +// +// Not a parser unit test. It stubs fetch with a Range-honouring server and calls +// createChunkedAudioEngine(...).ready, so it covers the widening fetch loop, the +// parser and the failure reporting together, through the module's public API. +// +// Run: node tests/js/wav-header.test.mjs +// +// Against the pre-fix engine this reports 25/38, with JUNK 4096, LIST 2 KB, +// JUNK 300 KB, the chained case and data-size-0 all loading at duration 0, and +// data-size-0xffffffff loading at 24347s. + +import { createChunkedAudioEngine } from "../../static/js/chunkedAudioEngine.js"; + +// -------------------------------------------------------------------------- +// WAV construction +// -------------------------------------------------------------------------- + +const SR = 44100, CH = 2, BITS = 16; +const SECONDS = 8; +const FRAMES = SR * SECONDS; + +function chunk(id, body) { + const pad = body.length & 1; + const b = Buffer.alloc(8 + body.length + pad); + b.write(id, 0, 4, "ascii"); + b.writeUInt32LE(body.length, 4); + body.copy(b, 8); + return b; +} + +function fmtPlain(audioFormat = 1, bits = BITS) { + const b = Buffer.alloc(16); + b.writeUInt16LE(audioFormat, 0); + b.writeUInt16LE(CH, 2); + b.writeUInt32LE(SR, 4); + b.writeUInt32LE(SR * CH * (bits >> 3), 8); + b.writeUInt16LE(CH * (bits >> 3), 12); + b.writeUInt16LE(bits, 14); + return chunk("fmt ", b); +} + +// WAVE_FORMAT_EXTENSIBLE: 40-byte fmt whose SubFormat GUID carries the real code. +function fmtExtensible(subFormat, bits) { + const b = Buffer.alloc(40); + b.writeUInt16LE(0xfffe, 0); + b.writeUInt16LE(CH, 2); + b.writeUInt32LE(SR, 4); + b.writeUInt32LE(SR * CH * (bits >> 3), 8); + b.writeUInt16LE(CH * (bits >> 3), 12); + b.writeUInt16LE(bits, 14); + b.writeUInt16LE(22, 16); // cbSize + b.writeUInt16LE(bits, 18); // validBitsPerSample + b.writeUInt32LE(3, 20); // channelMask + b.writeUInt16LE(subFormat, 24); // first field of the SubFormat GUID + return chunk("fmt ", b); +} + +function buildWav({ pre = [], fmt = fmtPlain(), bits = BITS, dataSizeOverride = null } = {}) { + const bytesPerFrame = CH * (bits >> 3); + const audio = Buffer.alloc(FRAMES * bytesPerFrame); + for (let i = 0; i < FRAMES; i++) { + if (bits === 16) audio.writeInt16LE(((i % 100) - 50) * 100, i * bytesPerFrame); + else audio.writeFloatLE(0.1, i * bytesPerFrame); + } + const dataHdr = Buffer.alloc(8); + dataHdr.write("data", 0, 4, "ascii"); + dataHdr.writeUInt32LE(dataSizeOverride ?? audio.length, 4); + + const body = Buffer.concat([Buffer.from("WAVE", "ascii"), fmt, ...pre, dataHdr, audio]); + const riff = Buffer.alloc(8); + riff.write("RIFF", 0, 4, "ascii"); + riff.writeUInt32LE(body.length, 4); + return Buffer.concat([riff, body]); +} + +const junk = (n) => chunk("JUNK", Buffer.alloc(n)); +const listInfo = (n) => + chunk("LIST", Buffer.concat([Buffer.from("INFO", "ascii"), chunk("ICMT", Buffer.alloc(n))])); + +// -------------------------------------------------------------------------- +// Fake Range server + AudioContext +// -------------------------------------------------------------------------- + +const FILES = new Map(); +let requests = []; + +globalThis.fetch = async (url, opts = {}) => { + const file = FILES.get(url); + if (!file) return { ok: false, status: 404, headers: { get: () => null }, arrayBuffer: async () => new ArrayBuffer(0) }; + const m = /bytes=(\d+)-(\d+)/.exec(opts.headers?.Range || ""); + if (!m) throw new Error("test server requires a Range header"); + const start = Number(m[1]); + const end = Math.min(Number(m[2]), file.length - 1); + requests.push({ url, start, end }); + const slice = file.subarray(start, end + 1); + return { + ok: true, + status: 206, + headers: { get: (h) => (h === "Content-Range" ? `bytes ${start}-${end}/${file.length}` : null) }, + arrayBuffer: async () => slice.buffer.slice(slice.byteOffset, slice.byteOffset + slice.byteLength), + }; +}; + +const node = () => ({ + gain: { value: 1, setTargetAtTime() {} }, + connect() {}, disconnect() {}, +}); +class FakeCtx { + constructor() { this.currentTime = 0; this.destination = {}; this.state = "running"; this.audioWorklet = null; } + createGain() { return node(); } + createAnalyser() { return { fftSize: 0, connect() {}, disconnect() {} }; } + createBuffer(ch, len, rate) { + return { numberOfChannels: ch, length: len, sampleRate: rate, duration: len / rate, + getChannelData: () => new Float32Array(len) }; + } + createBufferSource() { + return { buffer: null, playbackRate: { value: 1 }, connect() {}, start() {}, stop() {}, disconnect() {} }; + } + async close() {} +} +globalThis.window = { AudioContext: FakeCtx }; +process.on("unhandledRejection", (e) => { console.error("UNHANDLED:", e); process.exitCode = 1; }); + +// -------------------------------------------------------------------------- +// Cases +// -------------------------------------------------------------------------- + +const EXPECTED_DUR = SECONDS; +const cases = [ + ["canonical 44-byte header", buildWav(), true], + ["WAVE_FORMAT_EXTENSIBLE pcm16", buildWav({ fmt: fmtExtensible(1, 16) }), true], + ["small LIST/INFO (200 B)", buildWav({ pre: [listInfo(200)] }), true], + ["JUNK 512 B", buildWav({ pre: [junk(512)] }), true], + // The two layouts that previously disabled playback outright (#358). + ["JUNK 4096 B", buildWav({ pre: [junk(4096)] }), true], + ["LIST/INFO 2 KB", buildWav({ pre: [listInfo(2048)] }), true], + // Multi-round-trip, and several metadata chunks in a row. + ["JUNK 300 KB", buildWav({ pre: [junk(300 * 1024)] }), true], + ["JUNK+LIST+JUNK chained", buildWav({ pre: [junk(4096), listInfo(9000), junk(70000)] }), true], + // Sizes a writer left unpatched: both used to yield a nonsense duration. + ["data size 0 (streamed)", buildWav({ dataSizeOverride: 0 }), true], + ["data size 0xffffffff", buildWav({ dataSizeOverride: 0xffffffff }), true], + // Rejections. + ["not a RIFF file", Buffer.alloc(4096, 0x41), false], + ["chunk size runs past EOF", (() => { const w = buildWav({ pre: [junk(64)] }); + w.writeUInt32LE(0x7fffffff, 16); return w; })(), false], +]; + +let pass = 0, fail = 0; +const check = (name, cond, detail = "") => { + if (cond) { pass++; console.log(`PASS ${name}`); } + else { fail++; console.log(`FAIL ${name}${detail ? " -- " + detail : ""}`); } +}; + +for (const [name, buf, shouldLoad] of cases) { + FILES.clear(); requests = []; + const url = `/stems/${name.replace(/\W+/g, "_")}.wav`; + FILES.set(url, buf); + + const eng = createChunkedAudioEngine([{ name: "vocals", url }]); + const ok = await eng.ready; + + if (shouldLoad) { + const dur = eng.getDuration(); + const hdrReqs = requests.filter((r) => r.start === 0).length; + check(`${name}: loads`, ok === true, `getLoadError=${JSON.stringify(eng.getLoadError())}`); + check(`${name}: duration ~${EXPECTED_DUR}s`, Math.abs(dur - EXPECTED_DUR) < 0.05, `got ${dur}`); + check(`${name}: <=4 header requests`, hdrReqs <= 4, `made ${hdrReqs}`); + } else { + check(`${name}: rejected`, ok === false, `duration=${eng.getDuration()}`); + check(`${name}: reports a reason`, typeof eng.getLoadError() === "string" && eng.getLoadError().length > 0); + } + eng.destroy(); +} + +// Unreachable stems must be distinguishable from unreadable ones. +FILES.clear(); requests = []; +{ + const eng = createChunkedAudioEngine([{ name: "vocals", url: "/missing.wav" }]); + const ok = await eng.ready; + check("404 stem: rejected", ok === false); + check("404 stem: says 'could not load', not 'format'", + /could not load/i.test(eng.getLoadError() || ""), eng.getLoadError()); + eng.destroy(); +} +{ + const eng = createChunkedAudioEngine([]); + const ok = await eng.ready; + check("no stems: rejected", ok === false); + check("no stems: distinct message", /no stem files/i.test(eng.getLoadError() || ""), eng.getLoadError()); + eng.destroy(); +} + +console.log(`\n${pass}/${pass + fail} checks passed`); +process.exit(fail ? 1 : 0); From 446af2635b1508e0292ab60799cf3c62b4e74c14 Mon Sep 17 00:00:00 2001 From: Thales Pereira <31625914+thcp@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:48:05 +0100 Subject: [PATCH 2/3] fix(player): tell the user when a track's audio fails to load A track whose stems could not be loaded left the studio looking normal and said nothing. The only trace was a console warning, which in a release desktop build has no reachable devtools, so the failure was invisible to the user and undiagnosable from a bug report. Working out why #343 could not play took a screenshot and a round trip for a hexdump. Both engines now record why ready() resolved false and expose it via getLoadError(), separating a stem that could not be fetched from one that could not be parsed or decoded -- those send the user somewhere completely different. The player puts that message in the error box above the track header. Playback errors reuse the import error box, so they are tagged: the player retracts its own message when another track loads, without wiping an import failure the user has not read yet. Nothing cleared that box on track switch before. The player also now retries with the full-decode engine when the chunked one cannot read a container, under the same RAM ceiling the missing-peaks swap uses. The browser's own decoder handles layouts the hand-rolled parser may not, so this turns "playback disabled" into "playback works" for the whole class of container problems behind #343. Closes #359 --- static/js/audioEngine.js | 29 ++++++++++++++++++++++++++++- static/js/job.js | 16 ++++++++++++++++ static/js/player.js | 35 ++++++++++++++++++++++++++++++++--- 3 files changed, 76 insertions(+), 4 deletions(-) diff --git a/static/js/audioEngine.js b/static/js/audioEngine.js index e9fa3b0..a8b52f3 100644 --- a/static/js/audioEngine.js +++ b/static/js/audioEngine.js @@ -55,18 +55,35 @@ export function createAudioEngine(stems, { onTime, onEnded, context } = {}) { // seek, loop jump, rate change, pause). The metronome watches this to know // when its already-scheduled clicks are stale and must be torn down. let _epoch = 0; + // Why ready() resolved false, in words fit to show a user. Mirrors the same + // accessor on the chunked engine so callers need not know which one they hold. + let _loadError = null; // Decode all stems up front AND load the SoundTouch worklet in parallel. // Resolves true once at least one stem is ready (worklet load is best-effort). const ready = (async () => { + // Counted so the failure can name a cause rather than arriving as a silent + // console warning (#359). A fetch that never landed and a file the decoder + // rejected are different problems for the user. + let unreachable = 0; + let undecodable = 0; + await Promise.all([ _workletReady, ...stems.map(async (s) => { if (!s?.url) return; + let bytes; try { const res = await fetch(s.url); if (!res.ok) throw new Error(`fetch ${res.status}`); - const buffer = await ctx.decodeAudioData(await res.arrayBuffer()); + bytes = await res.arrayBuffer(); + } catch (e) { + unreachable++; + console.warn(`[audioEngine] fetch failed for ${s.name}:`, e); + return; + } + try { + const buffer = await ctx.decodeAudioData(bytes); if (destroyed) return; const gain = ctx.createGain(); const analyser = ctx.createAnalyser(); @@ -76,10 +93,19 @@ export function createAudioEngine(stems, { onTime, onEnded, context } = {}) { tracks.set(s.name, { buffer, gain, analyser, source: null }); duration = Math.max(duration, buffer.duration); } catch (e) { + undecodable++; console.warn(`[audioEngine] decode failed for ${s.name}:`, e); } }), ]); + + if (tracks.size === 0) { + _loadError = undecodable + ? "This track's audio files are in a format StemDeck could not read." + : unreachable + ? "Could not load this track's audio files." + : "This track has no stem files to play."; + } return tracks.size > 0; })(); @@ -201,6 +227,7 @@ export function createAudioEngine(stems, { onTime, onEnded, context } = {}) { return { ready, + getLoadError: () => _loadError, play, pause, seek, diff --git a/static/js/job.js b/static/js/job.js index d2504a7..5f491a5 100644 --- a/static/js/job.js +++ b/static/js/job.js @@ -95,6 +95,7 @@ function stopJobPolling() { // anything else. Export failures pass retry:false and get a plain Dismiss, since // the error box has no other way to be cleared. export function showError(message, detail, { retry = true } = {}) { + delete errorEl.dataset.kind; // see showPlaybackError errorEl.textContent = ""; const msg = document.createElement("div"); msg.className = "error-msg"; @@ -123,10 +124,25 @@ export function showError(message, detail, { retry = true } = {}) { } function clearImportError() { + delete errorEl.dataset.kind; errorEl.classList.add("hidden"); errorEl.textContent = ""; } +// Playback failures reuse the import error box, which is the only alert surface +// the studio has. They are tagged so the player can retract its own message when +// the user loads a different track, without wiping an import failure the user +// has not read yet. Always retry:false -- "Try again" sends the user to the URL +// field, which is not what a broken stem file calls for. +export function showPlaybackError(message, detail) { + showError(message, detail, { retry: false }); + errorEl.dataset.kind = "playback"; +} + +export function clearPlaybackError() { + if (errorEl.dataset.kind === "playback") clearImportError(); +} + // Clear the import chrome (progress box, error, phrase rotation, foreground // SSE) without touching the studio. Split out of reset() so a submit that goes // to the back of the queue does not tear down audio the user is playing. diff --git a/static/js/player.js b/static/js/player.js index f02acd8..d8b7bb0 100644 --- a/static/js/player.js +++ b/static/js/player.js @@ -1,5 +1,10 @@ import Multitrack from "/vendor/multitrack.js"; import { fmtTime } from "./utils.js"; +// job.js imports this module in turn. The cycle is pre-existing (catalog.js does +// the same) and safe: these are only ever called from a callback, long after both +// module bodies have run, and they close over DOM handles from dom.js rather than +// job.js state. +import { showPlaybackError, clearPlaybackError } from "./job.js"; import { STEM_NAMES, TRACK_NAMES, STEM_COLORS, PROGRESS_COLOR, LOOP_DEFAULT_START_FRAC, LOOP_DEFAULT_END_FRAC, LANE_VOLUME_MAX, @@ -1250,6 +1255,9 @@ export function wireUpAudio(jobId, stems, duration, thumbnail, mixUrl = null, ti // backend's documented degradation for missing peaks is client-side // decode — which only the full-decode engine can provide. const startEngine = (kind) => { + // Retract a previous track's playback failure. Without this the box + // stays up over a track that plays fine, since nothing else clears it. + clearPlaybackError(); const eng = kind === "chunked" ? createChunkedAudioEngine(stems, { onTime: driveTransportUi, onEnded }) : createAudioEngine(stems, { onTime: driveTransportUi, onEnded }); @@ -1264,11 +1272,28 @@ export function wireUpAudio(jobId, stems, duration, thumbnail, mixUrl = null, ti } if (!ok) { // No usable stems — drop the engine (null-URL multitrack stays mounted). - console.warn("[player] audio engine had no usable stems; playback disabled"); + const reason = eng.getLoadError?.(); teardownMetronome(); - updateMetronomeAvailability(null, "Playback unavailable for this track"); eng.destroy(); - setAudioEngine(null); + if (audioEngine === eng) setAudioEngine(null); + + // The chunked engine parses WAV containers itself, so a layout it + // cannot read disables playback on a file the browser's own decoder + // would have handled (#343). Try that decoder before giving up, + // under the same RAM ceiling the missing-peaks swap below uses. + if (kind === "chunked" + && estimateDecodedBytes(totalDuration, engineStemCount) <= MAX_ENGINE_DECODED_BYTES) { + console.warn("[player] chunked engine could not read these stems; trying full decode:", reason); + startEngine("fulldecode"); + return; + } + + console.warn("[player] audio engine had no usable stems; playback disabled:", reason); + updateMetronomeAvailability(null, "Playback unavailable for this track"); + showPlaybackError( + reason || "This track's audio could not be loaded.", + "Playback is disabled for this track. Other tracks are not affected.", + ); return; } eng.setLoop(loopEnabled, loopStart, loopEnd); @@ -1371,6 +1396,10 @@ export function wireUpAudio(jobId, stems, duration, thumbnail, mixUrl = null, ti updateMetronomeAvailability(null, "Playback unavailable for this track"); eng.destroy(); if (audioEngine === eng) setAudioEngine(null); + showPlaybackError( + "This track's audio could not be loaded.", + "Playback is disabled for this track. Other tracks are not affected.", + ); }); }; // Default: chunked streaming engine (fast start, low RAM). "fulldecode" From 82e7f297b242d95dc14e94999f493a7329da4c78 Mon Sep 17 00:00:00 2001 From: Thales Pereira <31625914+thcp@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:01:35 +0100 Subject: [PATCH 3/3] fix(player): reject sample formats the chunked engine cannot decode _pcmToAudioBuffer only handles 16-bit PCM and 32-bit float, but the header parser accepted any depth. A 24-bit or 32-bit-integer file therefore measured correctly, reported ready, and then decoded to nothing on every chunk. That is worse than failing outright. An all-empty chunk result is treated as a transient network failure and evicted from the cache, so the scheduler retries it on the next animation frame, forever, with the playhead pinned at zero and no message on screen. Measured against a synthetic 24-bit file with playback running: 82 range requests in 700 ms (~117/sec) versus 3 for a healthy file. Reject those formats at parse time instead. The engine then reports a readable reason and the player hands the file to the full-decode engine, whose decoder handles 24-bit and integer PCM -- so these files now play instead of hanging. Verified end to end with a real ffmpeg-produced 24-bit stem: the chunked engine declines it, the fallback picks it up, the transport advances, and playback issues no further range requests. Also covers WAVE_FORMAT_EXTENSIBLE float32, which is only accepted because the real format code is read out of the SubFormat GUID; without that it reads as 0xfffe and is now correctly rejected rather than silently decoding to nothing. --- static/js/chunkedAudioEngine.js | 11 +++++++++++ tests/js/wav-header.test.mjs | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/static/js/chunkedAudioEngine.js b/static/js/chunkedAudioEngine.js index 6281531..d766260 100644 --- a/static/js/chunkedAudioEngine.js +++ b/static/js/chunkedAudioEngine.js @@ -99,6 +99,17 @@ function _parseWavHeader(buf, fileSize = 0) { const bytesPerFrame = channels * (bitsPerSample >> 3); if (!bytesPerFrame) return { invalid: true }; + // Reject sample formats _pcmToAudioBuffer cannot turn into samples, rather + // than accepting the file on the strength of a readable header. Measuring a + // file we cannot decode is worse than rejecting it: every chunk comes back + // empty, _scheduledTo never advances, and because an empty result is treated + // as a transient failure and evicted from the cache, the scheduler re-fetches + // the same range on every animation frame. Rejecting hands the file to the + // full-decode fallback, whose decoder handles 24-bit and integer formats. + if (!(bitsPerSample === 16 || (audioFormat === 3 && bitsPerSample === 32))) { + return { invalid: true }; + } + // `data` may declare a size the file does not actually have: 0 and 0xffffffff // are both used by writers that stream to a non-seekable target and never go // back to patch the length. Either would yield a nonsense duration, and a diff --git a/tests/js/wav-header.test.mjs b/tests/js/wav-header.test.mjs index 3bfb1c5..9f8bae1 100644 --- a/tests/js/wav-header.test.mjs +++ b/tests/js/wav-header.test.mjs @@ -143,7 +143,18 @@ const cases = [ // Sizes a writer left unpatched: both used to yield a nonsense duration. ["data size 0 (streamed)", buildWav({ dataSizeOverride: 0 }), true], ["data size 0xffffffff", buildWav({ dataSizeOverride: 0xffffffff }), true], + // Sample formats. float32 decodes; the EXTENSIBLE variant only does so + // because the real format code is read out of the SubFormat GUID -- without + // that it reads as 0xfffe and is rejected here. + ["float32", buildWav({ fmt: fmtPlain(3, 32), bits: 32 }), true], + ["EXTENSIBLE float32", buildWav({ fmt: fmtExtensible(3, 32), bits: 32 }), true], // Rejections. + // Neither depth can be turned into samples, and accepting them was worse than + // rejecting: the header measured fine, so every chunk came back empty and the + // scheduler re-fetched the same range on every frame. Rejected, they fall + // through to the full-decode engine, whose decoder handles both. + ["24-bit PCM", buildWav({ fmt: fmtPlain(1, 24), bits: 24 }), false], + ["32-bit integer PCM", buildWav({ fmt: fmtPlain(1, 32), bits: 32 }), false], ["not a RIFF file", Buffer.alloc(4096, 0x41), false], ["chunk size runs past EOF", (() => { const w = buildWav({ pre: [junk(64)] }); w.writeUInt32LE(0x7fffffff, 16); return w; })(), false],