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: gapless live radio (frame-aligned segments + MP3 reservoir primer)

The live/streamed path sliced the byte stream blindly every 32 KB. Each
boundary loses audio: the codec drops the partial frame at the slice end,
re-syncs past the partial frame at the next slice's start, and MP3
bit-reservoir back-references break — an audible cut every ~2 s of radio
(reported on a Zeno.fm 128 kbps stream; confirmed it is NOT worklet
underruns — the loss is inside the decoded PCM).

Fix:
- Parse MPEG-audio / ADTS frame headers in JS (parseMpaFrame/parseAdtsFrame)
and cut segments only on frame boundaries (takeAlignedSegment, with
verified sync + resync-on-glitch; blind slicing kept as the fallback for
unframed formats like Ogg).
- Prepend the previous segment's last frames (3 for MP3 — reservoir depth,
1 for AAC) as a decode primer, and drop the surplus PCM at the head
(surplus = actual - segment's own expected frames, so it stays exact even
when the decoder skips unprimed frames).
- Live buffering hardened: worklet high-water 5 s for live (the 0.6 s
manual-crossfade cap no longer starves radio), no crossfade tail-holdback
for live, prebuffer 2.5 → 3 s.

Also: ignore the example's .vite dev cache (and untrack the copy + a stray
test sample that slipped into the index).

+138 -18
+1
bindings/wasm/example/.gitignore
··· 8 8 yarn.lock 9 9 pnpm-lock.yaml 10 10 .DS_Store 11 + .vite
-3
bindings/wasm/example/.vite/deps_temp_ae140b3f/package.json
··· 1 - { 2 - "type": "module" 3 - }
+137 -15
bindings/wasm/src/rockbox-decoder-worker.js
··· 27 27 /* global RockboxModule */ 28 28 29 29 const LIVE_SEGMENT = 32 * 1024; // encoded bytes per live-radio decode 30 - const LIVE_PREBUFFER_SEC = 2.5; // buffer before starting live playback 30 + const LIVE_PREBUFFER_SEC = 3; // buffer before starting live playback 31 31 const CHUNK = 8192; // i16 samples per PCM post (4096 frames) 32 32 33 33 let Module = null; ··· 272 272 273 273 /** Outgoing tail to keep for the fade-out (frames). */ 274 274 function holdFrames() { 275 - if (xfadeCfg.mode === 'off') return 0; 275 + if (live || xfadeCfg.mode === 'off') return 0; // radio doesn't crossfade 276 276 return Math.round((xfadeCfg.foDelay + xfadeCfg.foDur) * sampleRate); 277 277 } 278 278 ··· 351 351 // Keep the worklet queue short when crossfade can trigger on a manual skip, 352 352 // so the fade starts near "now" (queued audio can't be pulled back). 353 353 const highWater = () => { 354 + if (live) return 5 * sampleRate; // radio: ride out network jitter 354 355 const manualCapable = xfadeCfg.mode !== 'off' && xfadeCfg.mode !== 'auto-skip'; 355 356 return manualCapable ? Math.round(0.6 * sampleRate) : 3 * sampleRate; 356 357 }; ··· 624 625 return runSegmentLoop(resp.body.getReader(), new Uint8Array(0), false, ext, token, url, i, demux); 625 626 } 626 627 628 + // ── Frame-aligned segmentation ──────────────────────────────────────────── 629 + // Slicing a stream at arbitrary byte offsets loses audio at EVERY boundary: 630 + // the codec drops the partial frame at the slice end, re-syncs past the 631 + // partial frame at the next slice's start, and (MP3) the bit reservoir's 632 + // back-references break — an audible gap/click every ~2 s of radio. So for 633 + // MP3 and ADTS AAC we parse frame headers in JS and cut only on frame 634 + // boundaries, and prepend the last frames of the previous segment as a 635 + // reservoir "primer" whose decoded samples are dropped from the output. 636 + 637 + /** Parse an MPEG-audio frame header at b[i] → {len, spf} or null. */ 638 + function parseMpaFrame(b, i) { 639 + if (i + 4 > b.length) return null; 640 + if (b[i] !== 0xff || (b[i + 1] & 0xe0) !== 0xe0) return null; 641 + const ver = (b[i + 1] >> 3) & 3; // 3=MPEG1, 2=MPEG2, 0=MPEG2.5, 1=reserved 642 + const layer = (b[i + 1] >> 1) & 3; // 1=III, 2=II, 3=I, 0=reserved 643 + if (ver === 1 || layer === 0) return null; 644 + const brIdx = b[i + 2] >> 4; 645 + const srIdx = (b[i + 2] >> 2) & 3; 646 + const pad = (b[i + 2] >> 1) & 1; 647 + if (brIdx === 0 || brIdx === 15 || srIdx === 3) return null; // free-format unsupported 648 + const v1 = ver === 3; 649 + const sr = (v1 ? [44100, 48000, 32000] 650 + : ver === 2 ? [22050, 24000, 16000] : [11025, 12000, 8000])[srIdx]; 651 + let table; 652 + if (layer === 3) table = v1 ? [0,32,64,96,128,160,192,224,256,288,320,352,384,416,448] 653 + : [0,32,48,56,64,80,96,112,128,144,160,176,192,224,256]; 654 + else if (layer === 2) table = v1 ? [0,32,48,56,64,80,96,112,128,160,192,224,256,320,384] 655 + : [0,8,16,24,32,40,48,56,64,80,96,112,128,144,160]; 656 + else table = v1 ? [0,32,40,48,56,64,80,96,112,128,160,192,224,256,320] 657 + : [0,8,16,24,32,40,48,56,64,80,96,112,128,144,160]; 658 + const br = table[brIdx] * 1000; 659 + let len, spf; 660 + if (layer === 3) { spf = 384; len = (Math.floor(12 * br / sr) + pad) * 4; } 661 + else if (layer === 2) { spf = 1152; len = Math.floor(144 * br / sr) + pad; } 662 + else { spf = v1 ? 1152 : 576; len = Math.floor((v1 ? 144 : 72) * br / sr) + pad; } 663 + return len >= 4 ? { len, spf } : null; 664 + } 665 + 666 + /** Parse an ADTS (AAC) frame header at b[i] → {len, spf} or null. */ 667 + function parseAdtsFrame(b, i) { 668 + if (i + 7 > b.length) return null; 669 + if (b[i] !== 0xff || (b[i + 1] & 0xf6) !== 0xf0) return null; 670 + const len = ((b[i + 3] & 0x03) << 11) | (b[i + 4] << 3) | (b[i + 5] >> 5); 671 + return len >= 7 ? { len, spf: 1024 } : null; 672 + } 673 + 674 + /** Frame-aligned cutter over the shared `pending` buffer. Returns null for 675 + * unsupported formats (caller falls back to blind slicing). */ 676 + function makeFramer(ext) { 677 + const parse = ext === 'mp3' ? parseMpaFrame : ext === 'aac' ? parseAdtsFrame : null; 678 + if (!parse) return null; 679 + return { parse, synced: false, primer: null, primerFrames: ext === 'mp3' ? 3 : 1 }; 680 + } 681 + 682 + /** 683 + * Cut the next whole-frame segment (≥ LIVE_SEGMENT bytes unless `flush`) off 684 + * `pending`. Returns `{seg, frames, spf, primerBytes}` and the shortened 685 + * pending, or null when more data is needed. 686 + */ 687 + function takeAlignedSegment(fr, pending, flush) { 688 + const { parse } = fr; 689 + let start = 0; 690 + if (!fr.synced) { 691 + // Find a verified sync: a valid header whose length lands on another one. 692 + let i = 0; 693 + for (;;) { 694 + if (i + 8 >= pending.length) return { need: pending }; // wait for more 695 + const f = parse(pending, i); 696 + if (f) { 697 + if (i + f.len + 8 > pending.length) return { need: pending.slice(i) }; 698 + if (parse(pending, i + f.len)) { start = i; fr.synced = true; break; } 699 + } 700 + i++; 701 + } 702 + } 703 + let off = start, frames = 0, spf = 0; 704 + const offs = []; 705 + while (off + 8 <= pending.length) { 706 + const f = parse(pending, off); 707 + if (!f) { fr.synced = false; break; } // glitch — cut what we have, resync after 708 + if (off + f.len > pending.length) break; // incomplete tail frame — wait 709 + offs.push(off); 710 + off += f.len; frames++; spf = f.spf; 711 + if (off - start >= LIVE_SEGMENT) break; 712 + } 713 + if (frames === 0) return { need: pending.slice(start) }; 714 + if (!flush && fr.synced && off - start < LIVE_SEGMENT) return { need: pending.slice(start) }; 715 + const seg = pending.slice(start, off); 716 + const pIdx = Math.max(0, frames - fr.primerFrames); 717 + return { 718 + seg, frames, spf, 719 + primerBytes: pending.slice(offs[pIdx], off), 720 + rest: pending.slice(off), 721 + }; 722 + } 723 + 627 724 /** 628 725 * Progressive segment decoder shared by live streams and streamed-finite MP3/AAC. 629 726 * Decodes `pending` (any pre-read head) then reads `reader` to the end, decoding 630 - * each ~32 KB segment with rb_decode_packet and streaming the PCM. Starts 631 - * playback once ~LIVE_PREBUFFER_SEC is queued. `demux` (or null) strips ICY. 727 + * each frame-aligned ~LIVE_SEGMENT chunk with rb_decode_packet and streaming the 728 + * PCM (gapless via the reservoir primer). Starts playback once 729 + * ~LIVE_PREBUFFER_SEC is queued. `demux` (or null) strips ICY. 632 730 */ 633 731 async function runSegmentLoop(reader, pending, done, ext, token, url, i, demux) { 634 732 streaming = true; ··· 638 736 Math.max(4096, highWater() - 8192)); 639 737 let gotMeta = false; 640 738 let started = false; 739 + const framer = makeFramer(ext); 641 740 const maybeStart = () => { 642 741 if (!started && !userPaused && wlQueued >= prebuf) { started = true; setWorkletPaused(false); } 643 742 }; 644 - const drainPending = async () => { 645 - while (token === loadToken && pending.length >= LIVE_SEGMENT) { 646 - const seg = pending.slice(0, LIVE_SEGMENT); 647 - pending = pending.slice(LIVE_SEGMENT); 648 - // Only mark meta as read once a segment actually decodes (the first 649 - // segment of an MP3 is often just ID3/album-art bytes and fails). 650 - if (await decodeSegment(seg, ext, token, !gotMeta, maybeStart)) gotMeta = true; 743 + const drainPending = async (flush) => { 744 + for (;;) { 745 + if (token !== loadToken) return; 746 + if (framer) { 747 + const cut = takeAlignedSegment(framer, pending, flush); 748 + if (cut.need !== undefined) { pending = cut.need; return; } 749 + pending = cut.rest; 750 + // Prepend the previous segment's tail frames so the decoder has the 751 + // MP3 bit-reservoir data; their PCM is dropped via expectPcmFrames. 752 + const payload = framer.primer ? concatBytes(framer.primer, cut.seg) : cut.seg; 753 + const ok = await decodeSegment(payload, ext, token, !gotMeta, maybeStart, 754 + cut.frames * cut.spf); 755 + framer.primer = cut.primerBytes; 756 + if (ok) gotMeta = true; 757 + } else { 758 + // Unknown framing (e.g. Ogg): blind fixed-size slices. 759 + if (!flush && pending.length < LIVE_SEGMENT) return; 760 + if (pending.length === 0) return; 761 + const seg = pending.slice(0, LIVE_SEGMENT); 762 + pending = pending.slice(Math.min(LIVE_SEGMENT, pending.length)); 763 + if (await decodeSegment(seg, ext, token, !gotMeta, maybeStart)) gotMeta = true; 764 + } 651 765 } 652 766 }; 653 767 try { 654 - await drainPending(); 768 + await drainPending(false); 655 769 while (token === loadToken && !done) { 656 770 const r = await reader.read(); 657 771 if (token !== loadToken) { await reader.cancel().catch(() => {}); return; } ··· 660 774 if (audio.length) pending = concatBytes(pending, audio); 661 775 } 662 776 done = r.done; 663 - await drainPending(); 777 + await drainPending(done); 664 778 } 665 - if (token === loadToken && pending.length) await decodeSegment(pending, ext, token, !gotMeta, maybeStart); 666 779 if (token === loadToken) { 667 780 if (!started && !userPaused) setWorkletPaused(false); 668 781 await finishTrack(token); ··· 698 811 699 812 /** Decode one self-contained encoded packet in memory and stream its PCM. 700 813 * Returns true if the segment produced audio. */ 701 - async function decodeSegment(bytes, ext, token, readMeta, maybeStart) { 814 + async function decodeSegment(bytes, ext, token, readMeta, maybeStart, expectPcmFrames = 0) { 702 815 const dataPtr = copyPacket(bytes); 703 816 const pcmPtr = Module._rb_decode_packet(dataPtr, bytes.length, allocPath(ext), outLenPtr, outRatePtr); 704 817 const len = readU32(outLenPtr); ··· 706 819 if (!pcmPtr || len < 2) { if (pcmPtr) Module._rb_buffer_free(pcmPtr, len); return false; } 707 820 if (readMeta) updateLiveMeta({ codec: ext, sample_rate: rate }); 708 821 822 + // Reservoir-primer trim: `expectPcmFrames` is what the segment's own frames 823 + // should produce; anything beyond it at the head is decoded primer audio 824 + // (already played as part of the previous segment) — drop it. Computing the 825 + // drop from the surplus (rather than a fixed primer size) stays correct 826 + // when the decoder itself skips unprimed frames. 709 827 let pos = 0; 828 + if (expectPcmFrames > 0) { 829 + const pcmFrames = len >> 1; 830 + if (pcmFrames > expectPcmFrames) pos = (pcmFrames - expectPcmFrames) * 2; 831 + } 710 832 await streamRaw(pcmPtr, len, rate, () => pos, (v) => (pos = v), token, maybeStart); 711 833 Module._rb_buffer_free(pcmPtr, len); 712 834 return true;