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.

fix(rockbox-wasm): duration, seek, and HE-AAC routing for progressive M4A; bump to 0.1.9

Three fixes to the progressive M4A/AAC (ADTS re-framing) path:

- Duration always 00:00: updateLiveMeta() hard-coded duration_ms:0 and
live:true, clobbering the real duration playMp4 parsed from the moov on the
first decoded segment. Now it preserves a known duration and reports the real
live flag. Genuine live/ICY streams are unaffected (they start at 0/true).

- Seek support: the streaming path has no whole-file rawPtr to reposition, but
the container's sample table maps time -> AAC frame (1024 samples) -> byte
offset. seek() now maps the target time to a byte offset, issues an HTTP Range
request from there, seeds a fresh demux at that sample, and resumes re-framing.
Captured for both the network (playMp4) and gapless-prefetch (playAdtsBuffer)
entry points; falls back to skip-forward if the server ignores Range (200).

- HE-AAC stutter: backward-compatible HE-AAC signals objType=2 (LC) with a
trailing SBR extension that doubles output to 2048 samples/frame. The
progressive path's primer trim assumes 1024/frame and dropped ~half of every
segment. mp4ParseEsds now rejects any ASC longer than the 2-byte plain-LC
config (catches backward-compat SBR/PS), routing it to the whole-file decoder
which handles SBR correctly and is seekable.

Verified against real Navidrome tracks: duration reports correctly, seeks land
frame-accurate and decode cleanly (server returns 206), and HE-AAC decodes
full-length at 44.1 kHz with no dropped segments.

+119 -15
+1 -1
bindings/wasm/package.json
··· 1 1 { 2 2 "name": "rockbox-wasm", 3 - "version": "0.1.8", 3 + "version": "0.1.9", 4 4 "description": "Rockbox audio decoders + DSP compiled to WebAssembly — a batteries-included browser player (FLAC/MP3/Vorbis/Opus/ALAC/AAC/WavPack/…, 10-band parametric EQ, live internet radio) with the wasm bundled in.", 5 5 "license": "GPL-2.0-or-later", 6 6 "author": "Tsiry Sandratraina",
+118 -14
bindings/wasm/src/rockbox-decoder-worker.js
··· 67 67 // Finite-track decoded PCM, kept alive so we can seek within it. 68 68 let rawPtr = 0, rawLen = 0, rawRate = 0, finitePos = 0; 69 69 70 + // Seek context for a progressive (ADTS-reframed) M4A/AAC stream, which has no 71 + // whole-file `rawPtr` to reposition. The container's sample table maps time → 72 + // byte offset (each AAC frame = 1024 samples at `codecRate`), so a seek issues 73 + // an HTTP Range request from that offset and resumes the re-framing there. 74 + // Null for non-seekable streams (live, progressive MP3 without a duration). 75 + let streamSeek = null; // { url, cfg, samples, codecRate, durationMs, tags } 76 + 70 77 // Gapless prefetch: the predicted next track's raw file bytes, fetched into 71 78 // memory during the current track's playback so an auto-advance decodes 72 79 // straight from RAM with no network wait (the "silence before the next track" ··· 462 469 const url = queue[i]; 463 470 464 471 freeRaw(); 472 + streamSeek = null; // any prior stream's seek table is stale 465 473 live = false; 466 474 streaming = false; 467 475 index = i; ··· 491 499 prefetch = null; 492 500 // Faststart AAC was pre-demuxed to ADTS during the previous track — stream 493 501 // it progressively (bounded memory). Everything else decodes whole-file. 494 - if (pf.adts) return playAdtsBuffer(pf.adts, url, i, token, pf.durationMs, pf.tags); 502 + if (pf.adts) return playAdtsBuffer(pf, url, i, token); 495 503 const ext = sniffExt(pf.bytes.subarray(0, 16)) || formatExt(null, url); 496 504 return playDecodedBytes(pf.bytes, ext, url, i, token, seekMs, autoplay); 497 505 } ··· 662 670 // HE-AAC / moov-last fall back to a whole-file byte cache. 663 671 const d = demuxMp4Buffer(buf); 664 672 prefetch = d.mode === 'aac' 665 - ? { url, adts: d.adts, durationMs: d.durationMs, tags: d.tags } 673 + ? { url, adts: d.adts, durationMs: d.durationMs, tags: d.tags, cfg: d.cfg, samples: d.samples } 666 674 : { url, bytes: buf }; 667 675 return; 668 676 } ··· 945 953 // fall back to whole-file decode (which is also seekable). 946 954 947 955 const MP4_EMPTY = new Uint8Array(0); 956 + // AAC sampling-frequency table indexed by the ASC `srIdx` (ISO/IEC 14496-3). 957 + const AAC_SR = [96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350]; 958 + const AAC_FRAME_SAMPLES = 1024; // LC-AAC frame length (the only profile we re-frame) 948 959 const be32 = (b, o) => b[o] * 0x1000000 + (b[o + 1] << 16) + (b[o + 2] << 8) + b[o + 3]; 949 960 const be64 = (b, o) => be32(b, o) * 0x100000000 + be32(b, o + 4); // < 2^53 for our files 950 961 const box4 = (b, o) => String.fromCharCode(b[o], b[o + 1], b[o + 2], b[o + 3]); ··· 993 1004 if (b[o++] !== 0x04) return null; vlen(); // DecoderConfigDescriptor 994 1005 if (b[o++] !== 0x40) return null; // objectTypeIndication: AAC 995 1006 o += 1 + 3 + 4 + 4; // streamType + bufSize + max/avg bitrate 996 - if (b[o++] !== 0x05) return null; vlen(); // DecoderSpecificInfo → ASC 1007 + if (b[o++] !== 0x05) return null; // DecoderSpecificInfo → ASC 1008 + const ascLen = vlen(); 997 1009 const a0 = b[o], a1 = b[o + 1]; 998 1010 const objType = (a0 >> 3) & 0x1f; 999 1011 const srIdx = ((a0 & 0x07) << 1) | (a1 >> 7); 1000 1012 const chan = (a1 >> 3) & 0x0f; 1001 - if (objType < 1 || objType > 4) return null; // HE-AAC/other → whole-file 1013 + if (objType < 1 || objType > 4) return null; // explicit HE-AAC/PS/other → whole-file 1002 1014 if (srIdx > 12) return null; // explicit/reserved rate 1003 1015 if (chan < 1 || chan > 7) return null; // 0 = program config element 1016 + // A plain AAC-LC (Main/SSR/LTP) stereo/mono ASC is exactly 2 bytes. Anything 1017 + // longer carries an extension — most importantly *backward-compatible* SBR 1018 + // (HE-AAC: objType stays 2 but a trailing 0x2B7 sync doubles the output rate, 1019 + // so each frame decodes to 2048 samples, not 1024). The progressive ADTS path 1020 + // assumes 1024/frame and would drop ~half of every segment (stutter). Route 1021 + // these to the seekable whole-file decoder, which handles SBR correctly. 1022 + if (ascLen > 2) return null; 1004 1023 return { profile: objType - 1, srIdx, chan }; 1005 1024 } 1006 1025 ··· 1204 1223 live = false; playing = true; userPaused = false; 1205 1224 Module._rb_dsp_flush(dsp); curInRate = 0; 1206 1225 curMeta = { codec: 'aac', duration_ms: demux.durationMs || 0, ...demux.tags }; 1226 + streamSeek = mkStreamSeek(url, demux); 1207 1227 postMessage({ type: 'track', index: i, url, live: false, metadata: curMeta }); 1208 1228 emitStatus(); 1209 1229 void prefetchNext(); ··· 1242 1262 let total = 0; for (const p of parts) total += p.length; 1243 1263 const adts = new Uint8Array(total); let off = 0; 1244 1264 for (const p of parts) { adts.set(p, off); off += p.length; } 1245 - return { mode: 'aac', adts, durationMs: demux.durationMs, tags: demux.tags }; 1265 + // cfg/samples let a later seek Range-refetch the source by byte offset. 1266 + return { mode: 'aac', adts, durationMs: demux.durationMs, tags: demux.tags, 1267 + cfg: demux.cfg, samples: demux.samples }; 1246 1268 } 1247 1269 1248 1270 /** Stream an in-memory ADTS buffer through the progressive path — a gapless 1249 - * transition into a pre-demuxed faststart-AAC prefetch. */ 1250 - async function playAdtsBuffer(adts, url, i, token, durationMs, tags) { 1271 + * transition into a pre-demuxed faststart-AAC prefetch. `pf` carries the 1272 + * demux cfg/samples so the track stays seekable (via a source Range-refetch). */ 1273 + async function playAdtsBuffer(pf, url, i, token) { 1251 1274 live = false; playing = true; userPaused = false; 1252 1275 Module._rb_dsp_flush(dsp); curInRate = 0; 1253 - curMeta = { codec: 'aac', duration_ms: durationMs || 0, ...(tags || {}) }; 1276 + curMeta = { codec: 'aac', duration_ms: pf.durationMs || 0, ...(pf.tags || {}) }; 1277 + streamSeek = pf.samples ? mkStreamSeek(url, pf) : null; 1254 1278 postMessage({ type: 'track', index: i, url, live: false, metadata: curMeta }); 1255 1279 emitStatus(); 1256 1280 void prefetchNext(); 1257 - return runSegmentLoop(memoryReader(adts), MP4_EMPTY, false, 'aac', token, url, i, null); 1281 + return runSegmentLoop(memoryReader(pf.adts), MP4_EMPTY, false, 'aac', token, url, i, null); 1258 1282 } 1259 1283 1260 1284 function skipAfterError(i) { ··· 1299 1323 playing = false; userPaused = false; live = false; streaming = false; 1300 1324 cancelPrefetch(); 1301 1325 freeRaw(); 1326 + streamSeek = null; 1302 1327 xfade = null; 1303 1328 dropHold(); 1304 1329 flushWorklet(); ··· 1318 1343 startTrack(next, 0, true); 1319 1344 } 1320 1345 function seek(ms) { 1321 - if (live || !rawPtr) return; // live isn't seekable 1322 - seekBaseMs = ms; 1346 + if (live) return; // live isn't seekable 1347 + ms = Math.max(0, ms | 0); 1348 + if (rawPtr) { 1349 + // Whole-file decoded track: reposition the cursor inside the PCM buffer. 1350 + seekBaseMs = ms; 1351 + xfade = null; 1352 + dropHold(); 1353 + flushWorklet(); 1354 + Module._rb_dsp_flush(dsp); 1355 + curInRate = 0; 1356 + finitePos = Math.min(rawLen, Math.floor(ms * rawRate / 1000) * 2); 1357 + return; 1358 + } 1359 + if (streamSeek) { void seekStream(ms); return; } // progressive M4A: Range-refetch 1360 + // else: not seekable (progressive MP3/AAC with no sample table) 1361 + } 1362 + 1363 + /** Seed a fresh Mp4AacDemux positioned at sample `si` (whose bytes begin at 1364 + * absolute file offset `filePos`), so runSegmentLoop re-frames forward from 1365 + * there — the mechanism behind a progressive-stream seek. */ 1366 + function seekedMp4Demux(ctx, si, filePos) { 1367 + const d = new Mp4AacDemux(false); 1368 + d.mode = 'aac'; 1369 + d.cfg = ctx.cfg; 1370 + d.samples = ctx.samples; 1371 + d.si = si; 1372 + d.filePos = filePos; 1373 + d.durationMs = ctx.durationMs; 1374 + d.tags = ctx.tags; 1375 + return d; 1376 + } 1377 + 1378 + /** Build the seek context captured while a re-framed M4A/AAC stream plays. */ 1379 + function mkStreamSeek(url, demux) { 1380 + return { 1381 + url, 1382 + cfg: demux.cfg, 1383 + samples: demux.samples, 1384 + codecRate: AAC_SR[demux.cfg.srIdx] || 44100, 1385 + durationMs: demux.durationMs || 0, 1386 + tags: demux.tags || {}, 1387 + }; 1388 + } 1389 + 1390 + /** Seek within a progressive (ADTS-reframed) M4A/AAC stream: map the target 1391 + * time to a sample → byte offset via the container's sample table, then 1392 + * Range-refetch the source from there and resume re-framing. */ 1393 + async function seekStream(ms) { 1394 + const ctx = streamSeek; 1395 + const n = ctx.samples.length; 1396 + const dur = ctx.durationMs || Math.round(n * AAC_FRAME_SAMPLES * 1000 / ctx.codecRate); 1397 + const target = Math.min(ms, dur); 1398 + let si = Math.floor(target / 1000 * ctx.codecRate / AAC_FRAME_SAMPLES); 1399 + si = Math.max(0, Math.min(si, n - 1)); 1400 + const byteOff = ctx.samples[si].off; 1401 + 1402 + const token = ++loadToken; // abort the running segment loop 1403 + seekBaseMs = Math.round(si * AAC_FRAME_SAMPLES * 1000 / ctx.codecRate); // frame-aligned 1323 1404 xfade = null; 1324 1405 dropHold(); 1325 1406 flushWorklet(); 1407 + setWorkletPaused(true); 1326 1408 Module._rb_dsp_flush(dsp); 1327 1409 curInRate = 0; 1328 - finitePos = Math.min(rawLen, Math.floor(ms * rawRate / 1000) * 2); 1410 + 1411 + let resp; 1412 + try { 1413 + resp = await fetch(ctx.url, { headers: { Range: `bytes=${byteOff}-` } }); 1414 + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); 1415 + } catch (err) { 1416 + postMessage({ type: 'error', message: `seek failed: ${ctx.url} (${err})`, index }); 1417 + return; 1418 + } 1419 + if (token !== loadToken) { resp.body?.cancel().catch(() => {}); return; } 1420 + 1421 + // A 206 body starts at byteOff; a 200 (server ignored Range) starts at 0 — 1422 + // seed filePos accordingly and let the demux skip forward to the sample. 1423 + const filePos = resp.status === 206 ? byteOff : 0; 1424 + const demux = seekedMp4Demux(ctx, si, filePos); 1425 + playing = true; userPaused = false; 1426 + emitStatus(); 1427 + return runSegmentLoop(resp.body.getReader(), MP4_EMPTY, false, 'aac', token, ctx.url, index, demux); 1329 1428 } 1330 1429 1331 1430 function setQueue(urls, autoplay) { ··· 1435 1534 1436 1535 // ── ICY metadata ──────────────────────────────────────────────────────────── 1437 1536 function updateLiveMeta(fields) { 1438 - curMeta = { ...(curMeta || {}), ...fields, duration_ms: 0 }; 1439 - postMessage({ type: 'track', index, url: queue[index], live: true, metadata: curMeta }); 1537 + // Merge new fields but KEEP any duration already known for the track. A 1538 + // re-framed M4A/AAC stream carries a real duration parsed from its moov 1539 + // (playMp4 sets curMeta.duration_ms); only genuine live/ICY streams have 1540 + // none. Report the real `live` flag, not a hard-coded one — the finite 1541 + // streaming path is not live. 1542 + curMeta = { duration_ms: 0, ...(curMeta || {}), ...fields }; 1543 + postMessage({ type: 'track', index, url: queue[index], live, metadata: curMeta }); 1440 1544 } 1441 1545 function onIcyTitle(title) { 1442 1546 const t = (title || '').trim();