A modern, network-enabled music player platform built on Rockbox technology. rockboxd.tsiry-sandratraina.com
rust deno navidrome airplay libadwaita zig mpris snapcast mpd rockbox audio subsonic
2

Configure Feed

Select the types of activity you want to include in your feed.

wasm: play extension-less URLs + stream big files (don't buffer whole)

Two fixes for playing arbitrary server URLs (e.g. /tracks/<id>):

1. Format detection no longer relies on the URL extension (there often isn't
one). Sniff the file's magic bytes (fLaC/OggS/RIFF/ftyp/ID3/frame-sync/…),
then fall back to Content-Type, then the URL extension.

2. Don't wait for the whole file before playing. MP3/AAC are self-syncing, so
they're now decoded progressively in ~32 KB segments as they download —
playback starts within ~a second and memory stays bounded (a big file is
never held whole). Non-self-syncing formats (FLAC/Ogg/ALAC/…) still buffer
then whole-file decode, since a mid-file chunk has no header.

Refactor: the live-radio segment loop is extracted to runSegmentLoop and
shared by live streams and streamed-finite MP3/AAC. A `streaming` flag keeps
play/stop/state correct for streamed (non-seekable) sources.

Trade-off: streamed MP3/AAC isn't seekable and reports duration 0 (segment
mode); seekable playback still applies to the buffered formats.

+122 -37
+122 -37
bindings/wasm/src/rockbox-decoder-worker.js
··· 11 11 * MessagePort; the worklet queues and plays it and reports back how much it has 12 12 * consumed / has queued so we can pace decoding and report elapsed time. 13 13 * 14 - * - Finite file (has Content-Length): fetched whole, tags via rb_meta_read_json, 15 - * decoded via rb_decode_file → full metadata, duration, seeking. 16 - * - Live stream (no Content-Length): read the network in ~32 KB segments, 17 - * decode each with rb_decode_packet, stream the PCM out. ICY StreamTitle is 18 - * demuxed for now-playing metadata. 14 + * - Finite MP3/AAC: streamed progressively as it downloads (~32 KB segments 15 + * via rb_decode_packet) so playback starts fast and a big file is never 16 + * held whole. Not seekable in this mode. 17 + * - Other finite files (FLAC/Ogg/ALAC/…): a mid-file chunk has no header, so 18 + * the whole stream is drained then decoded with rb_decode_file → tags, 19 + * duration, seeking. 20 + * - Live stream (no Content-Length): same ~32 KB segment path; ICY StreamTitle 21 + * is demuxed for now-playing metadata. 22 + * 23 + * Format is detected from the file's magic bytes, then Content-Type, then the 24 + * URL extension (so extension-less URLs like /tracks/<id> work). 19 25 */ 20 26 21 27 /* global RockboxModule */ ··· 45 51 let shuffle = false; 46 52 let curMeta = null; 47 53 let live = false; 54 + let streaming = false; // a progressive segment loop (live or streamed-finite) is active 48 55 let loadToken = 0; // bumped on every track change / stop 49 56 let curInRate = 0; 50 57 ··· 151 158 const m = /\.([A-Za-z0-9]{1,5})(?:[?#]|$)/.exec(url); 152 159 return m ? m[1].toLowerCase() : 'bin'; 153 160 } 161 + 162 + /** Detect the audio format from a buffer's magic bytes; returns a Rockbox file 163 + * extension or null. Handles the common cases so extension-less URLs decode. */ 164 + function sniffExt(b) { 165 + if (b.length < 12) return null; 166 + const tag = (i, s) => { for (let j = 0; j < s.length; j++) if (b[i + j] !== s.charCodeAt(j)) return false; return true; }; 167 + if (tag(0, 'fLaC')) return 'flac'; 168 + if (tag(0, 'OggS')) return 'ogg'; // vorbis / opus / speex 169 + if (tag(0, 'RIFF') && tag(8, 'WAVE')) return 'wav'; 170 + if (tag(0, 'FORM') && tag(8, 'AIFF')) return 'aiff'; 171 + if (tag(4, 'ftyp')) return 'm4a'; // MP4 / M4A (AAC, ALAC) 172 + if (tag(0, 'wvpk')) return 'wv'; // WavPack 173 + if (tag(0, 'MAC ')) return 'ape'; // Monkey's Audio 174 + if (tag(0, 'TTA1')) return 'tta'; // True Audio 175 + if (tag(0, 'MPCK') || tag(0, 'MP+')) return 'mpc'; // Musepack 176 + if (tag(0, '.snd')) return 'au'; 177 + if (tag(0, 'ID3')) return 'mp3'; // ID3-tagged MP3 178 + if (b[0] === 0xff && (b[1] & 0xe6) === 0xe2) return 'mp3'; // MPEG audio frame sync 179 + if (b[0] === 0xff && (b[1] & 0xf6) === 0xf0) return 'aac'; // ADTS AAC 180 + return null; 181 + } 154 182 function formatExt(contentType, url) { 155 183 const ct = (contentType || '').toLowerCase(); 156 184 if (ct.includes('mpeg') || ct.includes('mp3')) return 'mp3'; ··· 169 197 170 198 freeRaw(); 171 199 live = false; 200 + streaming = false; 172 201 index = i; 173 202 curInRate = 0; 174 203 seekBaseMs = seekMs || 0; ··· 192 221 return playFinite(resp, url, i, token, seekMs, autoplay); 193 222 } 194 223 195 - /** Finite file: buffer whole → tags + full decode → seekable playback. */ 224 + /** 225 + * Finite file. Sniff the head, then either: 226 + * - MP3/AAC — decode progressively in ~32 KB segments as it downloads, so 227 + * playback starts within ~a second and memory stays bounded (a big file 228 + * is never held whole). Not seekable in this mode. 229 + * - anything else (FLAC/Ogg/ALAC/…) — a mid-file chunk has no header, so we 230 + * drain the whole stream then whole-file decode (tags, duration, seeking). 231 + */ 196 232 async function playFinite(resp, url, i, token, seekMs, autoplay) { 197 - let bytes; 198 - try { bytes = new Uint8Array(await resp.arrayBuffer()); } 199 - catch (err) { 200 - postMessage({ type: 'error', message: `fetch failed: ${url} (${err})`, index: i }); 201 - if (token === loadToken) skipAfterError(i); 202 - return; 233 + const reader = resp.body.getReader(); 234 + let head = new Uint8Array(0); 235 + let done = false; 236 + while (head.length < 16 && !done) { 237 + let r; 238 + try { r = await reader.read(); } 239 + catch (err) { 240 + postMessage({ type: 'error', message: `fetch failed: ${url} (${err})`, index: i }); 241 + if (token === loadToken) skipAfterError(i); 242 + return; 243 + } 244 + if (token !== loadToken) { reader.cancel().catch(() => {}); return; } 245 + if (r.value && r.value.length) head = concatBytes(head, r.value); 246 + done = r.done; 203 247 } 204 - if (token !== loadToken) return; 248 + // Magic bytes → Content-Type → URL extension. 249 + const ext = sniffExt(head) || formatExt(resp.headers.get('content-type'), url); 250 + 251 + if (ext === 'mp3' || ext === 'aac') { 252 + live = false; playing = true; userPaused = false; 253 + Module._rb_dsp_flush(dsp); curInRate = 0; 254 + curMeta = { codec: ext, duration_ms: 0 }; 255 + postMessage({ type: 'track', index: i, url, live: false, metadata: curMeta }); 256 + emitStatus(); 257 + return runSegmentLoop(reader, head, done, ext, token, url, i, null); 258 + } 259 + 260 + // Buffer the rest, then whole-file decode (seekable). 261 + const bytes = await drainToBytes(reader, head, done, token); 262 + if (!bytes || token !== loadToken) return; 205 263 206 - const path = `/track.${extOf(url)}`; 264 + const path = `/track.${ext}`; 207 265 Module.FS.writeFile(path, bytes); 208 266 const p = allocPath(path); 209 267 curMeta = readMetaJson(p); 210 268 applyTrackReplaygain(curMeta); 269 + Module._rb_dsp_flush(dsp); curInRate = 0; 211 270 rawPtr = Module._rb_decode_file(p, outLenPtr, outRatePtr); 212 271 rawLen = Module.HEAPU32[outLenPtr >> 2]; 213 272 rawRate = Module.HEAPU32[outRatePtr >> 2] || (curMeta && curMeta.sample_rate) || sampleRate; ··· 218 277 return; 219 278 } 220 279 finitePos = Math.min(rawLen, Math.floor((seekMs || 0) * rawRate / 1000) * 2); 221 - 222 280 postMessage({ type: 'track', index: i, url, live: false, metadata: curMeta }); 223 281 if (autoplay || playing) { playing = true; userPaused = false; setWorkletPaused(false); } 224 282 emitStatus(); 283 + const finished = await streamRaw(rawPtr, rawLen, rawRate, () => finitePos, (v) => (finitePos = v), token); 284 + if (finished && token === loadToken) { freeRaw(); advanceAfterEnd(); } 285 + } 225 286 226 - const done = await streamRaw(rawPtr, rawLen, rawRate, () => finitePos, (v) => (finitePos = v), token); 227 - if (done && token === loadToken) { freeRaw(); advanceAfterEnd(); } 287 + /** Drain the rest of `reader` (after `head`) into one buffer. */ 288 + async function drainToBytes(reader, head, done, token) { 289 + const chunks = head.length ? [head] : []; 290 + let total = head.length; 291 + while (!done) { 292 + let r; 293 + try { r = await reader.read(); } catch (_) { return null; } 294 + if (token !== loadToken) { reader.cancel().catch(() => {}); return null; } 295 + if (r.value && r.value.length) { chunks.push(r.value); total += r.value.length; } 296 + done = r.done; 297 + } 298 + const out = new Uint8Array(total); 299 + let off = 0; 300 + for (const c of chunks) { out.set(c, off); off += c.length; } 301 + return out; 228 302 } 229 303 230 304 /** Live stream: read the network, decode ~32 KB segments, stream the PCM. */ 231 305 async function playLiveStream(resp, url, i, token, autoplay) { 232 306 live = true; 233 307 playing = true; userPaused = false; 308 + Module._rb_dsp_flush(dsp); curInRate = 0; 234 309 const ext = formatExt(resp.headers.get('content-type'), url); 235 310 236 311 let metaint = 0; ··· 255 330 emitStatus(); 256 331 257 332 const demux = metaint > 0 ? new IcyDemux(metaint, onIcyTitle) : null; 258 - const reader = resp.body.getReader(); 333 + return runSegmentLoop(resp.body.getReader(), new Uint8Array(0), false, ext, token, url, i, demux); 334 + } 335 + 336 + /** 337 + * Progressive segment decoder shared by live streams and streamed-finite MP3/AAC. 338 + * Decodes `pending` (any pre-read head) then reads `reader` to the end, decoding 339 + * each ~32 KB segment with rb_decode_packet and streaming the PCM. Starts 340 + * playback once ~LIVE_PREBUFFER_SEC is queued. `demux` (or null) strips ICY. 341 + */ 342 + async function runSegmentLoop(reader, pending, done, ext, token, url, i, demux) { 343 + streaming = true; 259 344 const prebuf = Math.round(LIVE_PREBUFFER_SEC * sampleRate); 260 - let pending = new Uint8Array(0); 261 345 let gotMeta = false; 262 346 let started = false; 263 - 264 347 const maybeStart = () => { 265 348 if (!started && !userPaused && wlQueued >= prebuf) { started = true; setWorkletPaused(false); } 266 349 }; 267 - 350 + const drainPending = async () => { 351 + while (token === loadToken && pending.length >= LIVE_SEGMENT) { 352 + const seg = pending.slice(0, LIVE_SEGMENT); 353 + pending = pending.slice(LIVE_SEGMENT); 354 + await decodeSegment(seg, ext, token, !gotMeta, maybeStart); 355 + gotMeta = true; 356 + } 357 + }; 268 358 try { 269 - for (;;) { 270 - const { done, value } = await reader.read(); 359 + await drainPending(); 360 + while (token === loadToken && !done) { 361 + const r = await reader.read(); 271 362 if (token !== loadToken) { await reader.cancel().catch(() => {}); return; } 272 - if (value && value.length) { 273 - const audio = demux ? demux.push(value) : value; 363 + if (r.value && r.value.length) { 364 + const audio = demux ? demux.push(r.value) : r.value; 274 365 if (audio.length) pending = concatBytes(pending, audio); 275 366 } 276 - while (token === loadToken && pending.length >= LIVE_SEGMENT) { 277 - const seg = pending.slice(0, LIVE_SEGMENT); 278 - pending = pending.slice(LIVE_SEGMENT); 279 - await decodeSegment(seg, ext, token, !gotMeta, maybeStart); 280 - gotMeta = true; 281 - if (token !== loadToken) return; 282 - } 283 - if (done) break; 367 + done = r.done; 368 + await drainPending(); 284 369 } 285 370 if (token === loadToken && pending.length) await decodeSegment(pending, ext, token, !gotMeta, maybeStart); 286 371 if (token === loadToken) { if (!started && !userPaused) setWorkletPaused(false); advanceAfterEnd(); } ··· 335 420 // ── Transport ─────────────────────────────────────────────────────────────── 336 421 function play() { 337 422 if (queue.length === 0) return; 338 - if (!live && !rawPtr) { startTrack(index >= 0 ? index : 0, 0, true); return; } 423 + if (!rawPtr && !live && !streaming) { startTrack(index >= 0 ? index : 0, 0, true); return; } 339 424 playing = true; userPaused = false; setWorkletPaused(false); 340 425 emitStatus(); 341 426 } 342 427 function pause() { userPaused = true; setWorkletPaused(true); emitStatus(); } 343 428 function stop() { 344 429 loadToken++; 345 - playing = false; userPaused = false; live = false; 430 + playing = false; userPaused = false; live = false; streaming = false; 346 431 freeRaw(); 347 432 flushWorklet(); 348 433 curMeta = null; ··· 379 464 function enqueue(url) { 380 465 queue.push(url); 381 466 emitQueue(); 382 - if (playing && !live && !rawPtr) startTrack(index >= 0 ? index : 0, 0, true); 467 + if (playing && !rawPtr && !live && !streaming) startTrack(index >= 0 ? index : 0, 0, true); 383 468 } 384 469 function clearQueue() { queue = []; index = -1; stop(); emitQueue(); } 385 470 ··· 446 531 447 532 // ── Events to the main thread ─────────────────────────────────────────────── 448 533 function stateName() { 449 - if (!playing && !live && !rawPtr) return 'stopped'; 534 + if (!playing && !live && !rawPtr && !streaming) return 'stopped'; 450 535 return userPaused ? 'paused' : 'playing'; 451 536 } 452 537 function emitStatus() {