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: fix track stopping after seconds — backpressure + drain-before-advance

The decode loop's happy path has no real await, so it chains through segments
on microtasks only. The worklet's 'level' reports (which update wlQueued) are
macrotask port messages and never got processed during the burst — wlQueued
stayed 0, backpressure never engaged, the whole track decoded and posted in
seconds, then the segment loop "finished" → advanceAfterEnd → stop →
flushWorklet dropped everything still queued. You heard only the few seconds
that played during the burst. (Masked until now: before the port-routing fix,
stop()'s flush was silently dropped, so the fully-posted track played on.)

Fixes:
- postPcm counts queued frames locally (wlQueued += frames); the worklet's
periodic reports overwrite with the truth. Backpressure now engages
regardless of message timing, capping memory at ~3 s of queued audio.
- waitForDrain(): after a track finishes decoding (segment loop AND buffered
path), wait for the worklet to actually PLAY the queue before
advanceAfterEnd — never flush un-played audio. Handles pause-during-drain,
seek-rewind (buffered path streams again), and a 2 s stall watchdog so
broken reports can't wedge queue advancement.
- startTrack pauses the worklet during load/prebuffer so track starts are
clean (segment loop unpauses at prebuffer, buffered path on play).
- decodeSegment returns success so metadata isn't marked as read when the
first segment is pure ID3/album-art bytes.

+58 -7
+58 -7
bindings/wasm/src/rockbox-decoder-worker.js
··· 118 118 const copy = Module.HEAP16.slice(start, start + procLen); // detached copy 119 119 Module._rb_buffer_free(procPtr, procLen); 120 120 pcmPort.postMessage({ pcm: copy.buffer }, [copy.buffer]); 121 + // Local accounting: count the queued frames immediately. The worklet's 122 + // periodic 'level' reports overwrite this with the truth, but those are 123 + // macrotasks — a tight decode loop chained on microtasks would never see 124 + // them, think the queue is empty, and blast the whole track through with 125 + // no backpressure. 126 + wlQueued += procLen >> 1; 121 127 } 122 128 123 129 const highWater = () => 3 * sampleRate; // keep ≤ ~3 s buffered in the worklet ··· 148 154 } 149 155 } 150 156 157 + /** 158 + * Wait for the worklet's queue to finish PLAYING before auto-advancing — 159 + * advancing early would flush audio that hasn't been heard yet (the "track 160 + * stops after a few seconds" bug: everything decoded fast, then the advance 161 + * flushed the queue). Returns 'done' | 'aborted' | 'again' (the optional 162 + * `again()` predicate signals a seek rewound the cursor, so stream more). 163 + * A stall watchdog bails out if the queue stops shrinking while unpaused 164 + * (broken level reports must not wedge the queue advance forever). 165 + */ 166 + async function waitForDrain(token, again) { 167 + let last = wlQueued; 168 + let stall = 0; 169 + for (;;) { 170 + if (token !== loadToken) return 'aborted'; 171 + if (again && again()) return 'again'; 172 + if (wlQueued <= 0) return 'done'; 173 + await sleep(50); 174 + if (userPaused) { stall = 0; continue; } 175 + if (wlQueued < last) { last = wlQueued; stall = 0; } 176 + else if (++stall > 40) return 'done'; // ~2 s with no progress 177 + } 178 + } 179 + 151 180 // ── Track lifecycle ───────────────────────────────────────────────────────── 152 181 function freeRaw() { 153 182 if (rawPtr) { Module._rb_buffer_free(rawPtr, rawLen); rawPtr = 0; rawLen = 0; } ··· 202 231 curInRate = 0; 203 232 seekBaseMs = seekMs || 0; 204 233 flushWorklet(); 234 + // Hold the worklet while the new track loads/prebuffers; the buffered path 235 + // unpauses on play and the segment loop unpauses once prebuffered. 236 + setWorkletPaused(true); 205 237 Module._rb_dsp_flush(dsp); 206 238 207 239 let resp; ··· 280 312 postMessage({ type: 'track', index: i, url, live: false, metadata: curMeta }); 281 313 if (autoplay || playing) { playing = true; userPaused = false; setWorkletPaused(false); } 282 314 emitStatus(); 283 - const finished = await streamRaw(rawPtr, rawLen, rawRate, () => finitePos, (v) => (finitePos = v), token); 284 - if (finished && token === loadToken) { freeRaw(); advanceAfterEnd(); } 315 + // Stream the decoded buffer; when it's fully posted, let the worklet PLAY 316 + // it out before advancing (advancing early would flush the queued tail). 317 + // A seek can rewind the cursor while draining — go stream again. 318 + for (;;) { 319 + const finished = await streamRaw(rawPtr, rawLen, rawRate, () => finitePos, (v) => (finitePos = v), token); 320 + if (!finished || token !== loadToken) return; 321 + const r = await waitForDrain(token, () => finitePos < rawLen); 322 + if (r === 'aborted') return; 323 + if (r === 'again') continue; 324 + break; 325 + } 326 + freeRaw(); 327 + advanceAfterEnd(); 285 328 } 286 329 287 330 /** Drain the rest of `reader` (after `head`) into one buffer. */ ··· 351 394 while (token === loadToken && pending.length >= LIVE_SEGMENT) { 352 395 const seg = pending.slice(0, LIVE_SEGMENT); 353 396 pending = pending.slice(LIVE_SEGMENT); 354 - await decodeSegment(seg, ext, token, !gotMeta, maybeStart); 355 - gotMeta = true; 397 + // Only mark meta as read once a segment actually decodes (the first 398 + // segment of an MP3 is often just ID3/album-art bytes and fails). 399 + if (await decodeSegment(seg, ext, token, !gotMeta, maybeStart)) gotMeta = true; 356 400 } 357 401 }; 358 402 try { ··· 368 412 await drainPending(); 369 413 } 370 414 if (token === loadToken && pending.length) await decodeSegment(pending, ext, token, !gotMeta, maybeStart); 371 - if (token === loadToken) { if (!started && !userPaused) setWorkletPaused(false); advanceAfterEnd(); } 415 + if (token === loadToken) { 416 + if (!started && !userPaused) setWorkletPaused(false); 417 + // Everything is decoded but the worklet may hold several seconds of 418 + // un-played audio — advancing now would flush it. Play it out first. 419 + if ((await waitForDrain(token)) === 'done') advanceAfterEnd(); 420 + } 372 421 } catch (err) { 373 422 if (token === loadToken) { postMessage({ type: 'error', message: `stream error: ${url} (${err})`, index: i }); advanceAfterEnd(); } 374 423 } 375 424 } 376 425 377 - /** Decode one self-contained encoded packet in memory and stream its PCM. */ 426 + /** Decode one self-contained encoded packet in memory and stream its PCM. 427 + * Returns true if the segment produced audio. */ 378 428 async function decodeSegment(bytes, ext, token, readMeta, maybeStart) { 379 429 const dataPtr = copyPacket(bytes); 380 430 const pcmPtr = Module._rb_decode_packet(dataPtr, bytes.length, allocPath(ext), outLenPtr, outRatePtr); 381 431 const len = Module.HEAPU32[outLenPtr >> 2]; 382 432 const rate = Module.HEAPU32[outRatePtr >> 2]; 383 - if (!pcmPtr || len < 2) { if (pcmPtr) Module._rb_buffer_free(pcmPtr, len); return; } 433 + if (!pcmPtr || len < 2) { if (pcmPtr) Module._rb_buffer_free(pcmPtr, len); return false; } 384 434 if (readMeta) updateLiveMeta({ codec: ext, sample_rate: rate }); 385 435 386 436 let pos = 0; 387 437 await streamRaw(pcmPtr, len, rate, () => pos, (v) => (pos = v), token, maybeStart); 388 438 Module._rb_buffer_free(pcmPtr, len); 439 + return true; 389 440 } 390 441 391 442 function skipAfterError(i) {