Commits
The Rocksky WebSocket client read `deviceId` from every server message, including
the `device_registered` broadcast carrying ANOTHER device's id when a new device
(e.g. a web/mobile miniplayer) joins. That overwrote its own id, so its
now-playing pushes were tagged with the wrong device and miniplayers mislabeled
the playback source.
Only adopt the id from the registration reply (status: "registered"); ignore
device_registered announcements.
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.
Progressive M4A/AAC playback re-frames MP4 samples to ADTS and decodes them
through the aac_bsf codec. Its update_playing_time() derives elapsed time as
(offset - first_frame_offset) * 8 / id3->bitrate, but the streaming shim
(rbcodec_open_stream) never sets id3->bitrate, so bitrate == 0 → integer
divide-by-zero → WASM "RuntimeError: divide by zero", killing playback of any
faststart-AAC track (and raw AAC radio) with a "stream error".
Guard the division: when bitrate is unknown (0) leave the elapsed indicator
untouched — the host tracks position from decoded PCM. Fix applied to both the
firmware source and the vendored rockbox-codecs copy.
Reproduced deterministically against the real failing track: without the guard,
the first decoded frame traps (bitrate=0, offset=935); with it, the whole file
decodes cleanly through the decode + DSP pipeline.
Faststart MP4/M4A files carrying plain AAC are now demuxed in the worker
and each container sample re-framed as a self-contained ADTS packet,
streamed through the same progressive rb_decode_packet path MP3 uses —
playback starts within ~a second with bounded memory instead of
downloading the whole file first. Also pre-demuxes the prefetched next
track to ADTS for gapless transitions. Non-faststart (moov last), ALAC,
and HE-AAC files fall back to the seekable whole-file path.
Some stream hosts (e.g. zeno.fm) reject requests with a missing/empty
User-Agent with 401 Unauthorized instead of serving the stream, so the
empty default made those stations silently fail to play. Always send
`rockbox-playback/<version>`.
Kick off prefetchNext() while an MP3/AAC track streams progressively so a
whole-file next track (FLAC/…) decodes from memory with no gap.
Make prefetchNext() re-callable and abortable: when the predicted
next-up track changes (e.g. "play next" insert or enqueue), abort any
in-flight prefetch for the old track and start the new one so its
transition stays gapless. Cancel prefetch on stop/setQueue/clearQueue.
Prefetch the predicted next track's bytes into memory during the current
track's playback so an auto-advance decodes straight from RAM with no
network wait. Whole-file (drain) formats only — MP3/AAC already start fast
via progressive streaming. Shared playDecodedBytes() feeds both the drain
path and the prefetch fast-path; prefetch state is cleared on queue replace
and clearQueue to avoid decoding a stale track.
Handle the 'skipTo' worker message via startTrack(index, 0, true)
instead of the removed beginManual(), and bump rockbox-wasm to 0.1.3.
Also bumps rockbox_ffi_nif to 1.1.1 in the Elixir lockfile.
fetchCargoVendor bundles a normalized Cargo.lock into its output, so the
rockbox-codecs 0.2.1 / rockbox-playback 0.4.1 / rockbox-ffi 0.3.1 bumps
changed the vendored-deps hash. Update it so `nix build` stops failing
with a hash mismatch.
sha256-JaAKEAyXXSmJwU/DBJOkwOwkTtn0aQuQrdq4xx+ZFVY=
-> sha256-xp2nWa6nX2Q2tV8q1cpvPHEgHPMLaJB3PFI6CaFrRE8=
Propagate the aac_bsf/HE-AAC decode fix (rockbox-codecs 0.2.1) to every
language binding that wraps the librockbox_ffi C ABI:
clojure 0.5.0 -> 0.5.1 (build.clj default)
elixir 0.6.0 -> 0.6.1
erlang 1.1.0 -> 1.1.1 (app.src vsn; gleam.toml realigned 1.0.0 -> 1.1.1)
gleam 1.5.0 -> 1.5.1
kotlin 0.5.0 -> 0.5.1
python 0.5.0 -> 0.5.1
ruby 0.5.0 -> 0.5.1
typescript 0.5.0 -> 0.5.1
go and swift carry no in-repo version field (versioned by git tags), and
wasm (0.1.2) was already bumped with the fix commit.
`afmt_for_ext("aac")` mapped raw ADTS/HE-AAC streams (content-type
audio/aac / audio/aacp) to AFMT_MP4_AAC, whose libm4a demuxer needs
moov/mdat atoms and fails outright on a raw ADTS frame stream — HE-AAC
internet radio played silence. Map "aac" to AFMT_AAC_BSF (the bitstream
decoder), matching Rockbox's own get_afmt_from_content_type() and the
.aac file-extension table. .m4a/.mp4 keep AFMT_MP4_AAC (they use the
seekable/file route, not forward streaming).
Verified: the alt64 aacPlus stream now decodes at 44100 Hz (SBR active).
Bump the affected crate chain (patch): rockbox-codecs 0.2.1,
rockbox-playback 0.4.1, rockbox-ffi 0.3.1, and rockbox-wasm 0.1.2
(ships the recompiled core).
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).
Refactor WASM build to lightweight core and enhance live streaming
Queue:
- Fix stale "n / m" counter — enqueue only emitted a `queue` event but the
UI renders status.queue_len; emitQueue() now also emits a status snapshot,
so "+ Queue" updates the count immediately.
- Queue panel in the example: track list (basename or now-playing title),
current-track highlight, click-to-play (skipTo), per-row remove, clear,
and export as .m3u8. Counter shows "– / n" when nothing is playing.
- removeAt(i) across worker/facade/typings with Rockbox semantics: removing
before current renumbers, removing current hard-cuts to the slide-in,
removing the playing tail stops (queue kept), last track stops.
- Rockbox insertion modes (apps/playlist.c): prepend / insert (chained after
the previous Insert batch, reset on track change) / play next / play last /
insert shuffled / last shuffled / replace / at-index. insert() on the
facade + InsertMode TS enum + a mode picker next to "+ Queue".
- Queue persists to localStorage via jotai (rb.queue): saved on every queue
event (guarded so the initial empty state can't wipe it), restored on boot
unless the boot action already set a queue.
M3U (plain JS — the queue is URLs): parseM3u / serializeM3u / isM3uUrl
statics, loadM3u(Url) / enqueueM3u(Url) / exportM3u on the player. Playing
an .m3u/.m3u8 URL loads the whole playlist; an Import button reads a local
playlist file; Export downloads the queue.
Icons: @tabler/icons-react replaces the emoji transport (play/pause, prev,
next, stop, shuffle, repeat with IconRepeatOnce for repeat-one, playlist
import/export/clear/remove, volume); shuffle/repeat highlight via the active
style when enabled.
Verified with headless Chrome against the Vite example: counter 1/1→1/2→1/3
live, panel rows track removals, 10 SVG icons render, shuffle highlights,
queue (3 tracks) survives reload and restores on boot, playback unaffected.
Root cause (found by driving headless Chrome against the real example and
instrumenting the served worker): emitPcm posted the PCM with a transfer
list and THEN read arr.length for the local accounting. postMessage transfer
detaches arr.buffer, so arr.length was already 0 — every chunk counted as
zero frames. Backpressure never engaged, the whole track "streamed" in a
couple of seconds, waitForDrain saw an empty queue, advanceAfterEnd stopped
the player, and its flush wiped the audio right as the delayed port messages
reached the worklet. The pre-crossfade code counted from procLen (a plain
number) before posting, which is why the crossfade refactor introduced this.
Fixes and hardening:
- emitPcm: read the frame count BEFORE the transfer.
- Queue accounting is now derived from monotonic counters
(wlQueued = postedFrames - consumed) instead of trusting the worklet's
point-in-time `queued` snapshot, which undercounts in-flight chunks.
- All wasm heap access goes through fresh views over the live buffer
(liveBuf/copyI16/readU32/writeU8) — cached Module.HEAPxx snapshots can
reference a detached buffer after ALLOW_MEMORY_GROWTH grows the heap
(a 4-minute AAC decodes to ~45 MB and grows it past the 64 MB initial).
Verified end-to-end with playwright-core driving system Chrome against the
Vite example and a real 10 MB M4A: plays continuously with elapsed counting,
crossfade off and Always.
Port the original pcmbuf crossfade (via its faithful extraction in
crates/rockbox-playback/src/crossfade.rs) into the decoder worker — no second
player needed: like pcmbuf, we mix at the PCM level into the single output
stream.
Worker:
- Q16 gains (MIXFADE_UNITY), Bresenham MixFader ramps, mixfade_sample
rounding and saturating clip16 — byte-faithful to apps/pcmbuf.c.
- Same parameters/semantics as the firmware: mode (off / auto track change /
manual skip / shuffle / shuffle-or-manual / always), fade-out delay 0-7 s +
duration 0-15 s, fade-in delay + duration, and fade-out mixmode
(crossfade = both fade, mix = outgoing stays full volume).
- Output pipeline: crossfade mixer -> tail holdback -> worklet. The newest
fade_out_delay+duration seconds are held back so a transition always has an
outgoing tail; natural end hands it to the next track (no flush, seamless),
manual skip fades from ~now (worklet high-water drops to 0.6 s when a
manual-capable mode is on so the response is quick).
- finalizeCrossfade plays the tail's fade out when the incoming track is
shorter than the region; per-track elapsed epoch (trackBase) keeps the
progress display sane across unflushed transitions.
API: setCrossfade(mode, {fadeOutDelay, fadeOutDuration, fadeInDelay,
fadeInDuration, mixMode}) with CrossfadeMode + CrossfadeMixMode exported as
TypeScript enums (raw ints also accepted); persisted and restored like the
other settings.
Example: Crossfade card in the DSP panel (mode, four fade sliders with
Rockbox ranges, mixmode), persisted via jotai.
Package README uses the absolute raw.githubusercontent URL so the image
renders on npmjs.com (which serves the README from the tarball); the example
README uses the relative path. preview.png is not in the npm "files" list, so
the tarball stays lean.
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.
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.
- Persist every player/DSP setting to localStorage with jotai atomWithStorage
(src/settings.ts); DspPanel + App read/write the atoms, and applySettings()
pushes them to the player once the engine boots so audio matches the
restored UI on reload.
- Resolve rockbox-wasm to the local build via a Vite alias + tsconfig paths
instead of a package dependency: the example lives inside the package, and
bun can't install a file:.. / workspace:* dep that points at its own parent.
Add @types/node for the vite config.
Verified: `bun install` + `bun run build` (tsc + vite) succeed.
pause/flush were posted over the Worker→worklet PCM port, but the worklet
only handled them on its node port. On the PCM port they fell through to the
PCM handler (new Int16Array(undefined) → an empty chunk) and were dropped, so
pause never set the silence flag and stop never cleared the queue — playback
just kept draining the ~3 s look-ahead buffer before the command "took
effect".
Handle paused/flush in the worklet's PCM-port handler (_onPcm) alongside PCM.
Now pause silences within a render quantum and stop/seek/track-change flush
the queue immediately.
- rockbox-ffi/dsp.rs: add rb_dsp_set_crossfeed (headphone crossfeed) and
rb_dsp_set_pbe (Perceptual Bass Enhancement), wrapping the rockbox-dsp
crate's crossfeed + pbe APIs. Export both in scripts/build-wasm.sh.
- Facade: setCrossfeed / setPbe, persisted + restored; add a CrossfeedMode
enum (Off/Meier/Custom) so crossfeed mode is human-readable (like Repeat/
ReplayGain/Channel), setter still accepts the raw int.
- Example: new DspPanel.tsx exposes every DSP option — ReplayGain (mode +
preamp + noclip), bass/treble, EQ pre-cut, crossfeed, PBE, Haas surround,
compressor, channel mode + stereo width — all via the enums, no magic
numbers in the UI.
Verified: cargo check + `bun run build` (tsc + vite) pass; new exports present.
- Switch the example to bun (bun install / bun run dev); pre-hooks call
node scripts/copy-wasm.mjs directly so they're package-manager-agnostic.
- Untrack the accidentally-committed package-lock.json and
tsconfig.tsbuildinfo, and gitignore lockfiles + *.tsbuildinfo.
- Console (wasm:example) and docs updated to bun.
Verified: `bun run build` succeeds.
- copy-wasm.mjs used require.resolve("rockbox-wasm"), which throws
ERR_PACKAGE_PATH_NOT_EXPORTED against the package's import-only exports.
The example lives inside the package, so read dist/ from ../../dist directly.
- useRockbox INITIAL_STATUS.repeat was 0; StatusEvent.repeat is now the
RepeatMode enum → use RepeatMode.Off.
Verified: `npm run build` (tsc + vite) succeeds.
Turn the browser build into a distributable, friction-free npm package.
Single-threaded (no SharedArrayBuffer, no COOP/COEP):
- Build without -pthread; decode synchronously on the calling thread via
rb_decode_packet / rb_decode_file (no codec thread). This also removes the
entire Emscripten-proxy / park-state crash class we hit with pthreads.
- Worker → AudioWorklet PCM transport switched from a SAB ring to a
MessagePort (transferable ArrayBuffers). Worklet queues + plays, reports
consumed/queued frames back for backpressure + elapsed.
- Finite files: rb_meta_read_json + rb_decode_file (seekable). Live radio:
rb_decode_packet per ~32 KB segment. ICY StreamTitle metadata kept.
npm package (bindings/wasm, moved from web/):
- SINGLE_FILE embeds the wasm into rockbox-core.js — nothing to serve.
- package.json (repo/homepage/bugs), index.d.ts with real TS enums
(RepeatMode/ReplayGainMode/ChannelMode; setters also take ints), README,
scripts/build.sh + scripts/publish.sh (always builds first).
- baseUrl option + ?core= worker param so assets resolve anywhere.
Example (bindings/wasm/example): Vite + React + TypeScript + Tailwind v4,
depends on the package via "file:..", copies dist → public/rockbox.
Console: wasm:build / wasm:example / wasm:dev / wasm:publish. Removed the old
static dev server (Vite serves the demo). Docs: WEBASSEMBLY.md, CLAUDE.md,
bindings/README.md.
The per-segment rb_decoder_open approach spawned a codec pthread per 32 KB
and drove it with a blocking next_chunk on the module main thread. Both are
unsafe on an Emscripten module main thread: it can't reliably block on a
channel recv or on pthread_create, so a proxied stdout/growth call re-enters
during the wait → "inconsistent park state" panic, and the pthread spawns
recurse into a stack overflow.
Add synchronous, thread-free decode (the "decode function" requested):
- rockbox-codecs: decode_file_sync(path) and decode_bytes_sync(bytes, ext)
run rbcodec_run() to completion on the CALLING thread with a Vec-appending
sink — no thread spawned, nothing blocks on a channel.
- rockbox-ffi: rb_decode_file(path,…) and rb_decode_packet(data,len,ext,…) —
"bytes in, PCM out", caller frees with rb_buffer_free.
Live radio (worker): playLiveStream now decodes each ~32 KB network segment
in memory via rb_decode_packet (no MEMFS, no pthread), runs it through the
DSP/resampler, and streams the PCM into the ring with backpressure. Finite
files keep the incremental threaded Decoder (one pthread, works).
Per request, drop the codec-side push-streaming and do the stream loop in JS.
Revert the Rust streaming work: remove rockbox-ffi stream.rs
(rb_stream_*/rb_decoder_open_stream), rb_decoder_try_next_chunk, and
rockbox-codecs Decoder::poll_chunk. The WASM surface is back to the plain
file decoder + DSP + metadata.
Live radio (rockbox-decoder-worker.js): detect via Content-Length, then
playLiveStream() reads the network ReadableStream, slices it into ~32 KB
segments, and decodes each with a throwaway rb_decoder_open (MEMFS file),
forwarding PCM to the ring. next_chunk only ever blocks briefly (segment is
fully in memory), so the module main thread never parks for real time — no
crash, no stall. A ~2.5 s prebuffer rides out segment-boundary jitter;
underruns just play silence and recover.
ICY metadata: request Icy-MetaData:1, and if the server exposes icy-metaint
(CORS permitting), demux the interleaved metadata out of the audio and parse
StreamTitle → now-playing artist/title. Also read icy-name (station) and
icy-br (bitrate). Falls back silently when CORS blocks it.
UI: show StreamTitle / station for live, and FORMAT · kHz · kbps for every
track.
The push StreamReader returned a partial read whenever the buffer held
fewer bytes than the codec asked for. Rockbox codecs treat a short read as
end-of-stream, so the codec stopped at the first gap — the stream played
for a moment then ended and the queue "advanced" to stopped.
Make the reader fill the whole requested buffer, blocking (on the codec
thread) for the feed to top it up; only a genuinely closed stream returns a
short/zero read. The main thread still polls non-blocking, so this can't
deadlock.
Radio played ~3 s then aborted: `thread 'rbcodec' panicked … inconsistent
park state` during a memory-growth fd_write, with the worker parked inside
rb_decoder_next_chunk. Emscripten proxies memory growth to the module's main
thread; blocking it there (rx.recv) stalls the proxy and corrupts the codec
pthread. A realtime stream parks it for whole seconds, so it crashes; finite
files rarely block long enough to hit it.
Fix: never park the main thread — poll instead.
- rockbox-codecs: add Decoder::poll_chunk (try_recv → Poll).
- rockbox-ffi: add rb_decoder_try_next_chunk (state: 0 chunk / 1 pending /
2 ended).
- worker pump: use the non-blocking poll for every track; on `pending`,
reschedule and yield to the event loop (which also lets the live feed run).
Removes the now-unneeded stream pull-gate.
Before, the worker did `await resp.arrayBuffer()`, which never resolves for
an unbounded stream — live radio never started.
rockbox-ffi: add a push-based byte stream (crates/rockbox-ffi/src/stream.rs):
rb_stream_new/feed/close/available/free + rb_decoder_open_stream over
Decoder::open_stream. The reader blocks on an empty-but-open buffer (waits
for more) and only EOFs when closed — so a live stream plays forever and a
network stall becomes silence, not end-of-track. Teardown closes the stream
before freeing the decoder so the codec thread's join can't hang.
Worker: branch on Content-Length (+ icy-* headers). Finite files keep the
MEMFS whole-file path (metadata, duration, seeking); live streams push the
fetch ReadableStream into the blocking reader with a prebuffer, an
available>threshold pull-gate (so decode never parks the single worker
thread), and ~8 MB backpressure so memory stays bounded. UI shows a LIVE
badge and disables seeking for infinite streams.
Every control started `disabled` and enableUI() only ran after
player.init(), but init() only runs on the first Play click — which was
disabled. Nothing ever enabled the UI. Leave the URL input + Play/Queue
buttons enabled as the entry point (they boot the AudioContext on first
click under the required user gesture); enableUI() then unlocks the rest.
Also make boot single-flight + retry-safe and drop the misleading
"Initialising…" message.
`bb wasm:dev` builds web/rockbox-core.{js,wasm} then starts the COOP/COEP
dev server so the web example comes up in one command. Also refresh the
wasm command docs for the new lightweight core (rockbox-core.* instead of
the old firmware rockboxd.*).
The WASM target compiled the entire Rockbox firmware (netstream, playlist
engine, servers, SDL) via a C bridge. Now that the core is extracted into
rockbox-codecs / rockbox-dsp / rockbox-metadata behind the rockbox-ffi flat
C ABI, compile only those into WASM and let the browser be the player.
Rust:
- rockbox-ffi: gate the cpal-backed `player` module behind a default
`player` feature so it can build for wasm32-unknown-emscripten with
--no-default-features (decode + DSP + metadata only).
- Delete crates/wasm (the firmware bridge).
Build:
- Rewrite scripts/build-wasm.sh: `cargo rustc -p rockbox-ffi
--no-default-features --crate-type staticlib` then emcc link into
web/rockbox-core.{js,wasm} (~1.2 MB). No firmware configure/make, no
codec objcopy dance. -Wl,--no-check-features for the prebuilt std objects.
- Dev server COEP require-corp -> credentialless so cross-origin webfonts load.
Web example (three small JS files, player entirely in JS):
- rockbox.js: main-thread RockboxPlayer facade (AudioContext, GainNode
volume, events).
- rockbox-decoder-worker.js: owns the WASM module; queue/transport/decode/
DSP/resample off the main thread (Decoder blocks on a Condvar), writes PCM
into a SharedArrayBuffer ring.
- rockbox-audio-worklet.js: reads the ring -> speakers.
- index.html: simplified UI (42k -> 18k), keeps the full 10-band EQ + DSP
config; Outfit + Lexend + JetBrains Mono fonts.
Docs: rewrite WEBASSEMBLY.md, web/README.md, CLAUDE.md WASM section.
Accept an http(s):// URL as the play target and only existence-check
local paths, aborting with a clear message on a missing file.
Add Android arm64-v8a + x86_64 librockbox_ffi.so to the Kotlin artifact so
an app depending on the Maven package gets the native lib packed into its APK.
- bindings-release.yml: new build-lib-android job cross-compiles
librockbox_ffi.so for aarch64/x86_64 via cargo-ndk + the runner's NDK
(--platform 26, clang/libclang for oboe-sys' bindgen). continue-on-error
like the BSD jobs; wired into the release job's needs so its .so land as
librockbox_ffi-android-{arm64,x64}.so release assets.
- fetch-libs.sh: download the android-{arm64,x64} .so and stage them at
bindings/kotlin/src/main/resources/lib/<abi>/librockbox_ffi.so — the path
AGP extracts JAR native libs from. --all covers them; --target android-*
is Kotlin-only.
- publish-kotlin.sh / build.gradle.kts / README: document the Android slices
(lib/<abi>/ for AGP; the desktop FFM loader is not used on Android).
Feat/queue remove clear
Add two queue-editing operations to the rockbox-playback API and
propagate them through the C ABI and all language bindings.
Core (rockbox-playback):
- Player::remove(index) — delete the track at a 0-based queue position.
Removing before the cursor shifts it back (current keeps playing);
removing the current track hard-cuts to the track that slides in;
removing the last track stops playback; out-of-range is a no-op.
- Player::clear_queue() — empty the queue, stop playback, clear resume.
- New Command::Remove/Clear, engine methods, and a pure, unit-tested
perform_remove() model mirroring perform_insert().
C ABI (rockbox-ffi + include/rockbox_ffi.h):
- rb_player_remove(RbPlayer*, size_t index)
- rb_player_clear_queue(RbPlayer*)
Bindings: Python, TypeScript, Go, Ruby, Kotlin, Swift, Clojure, Erlang
(NIF), Elixir and Gleam all gain the two methods following each
binding's existing conventions, with tests/docs where present.
Tests: perform_remove unit tests, device-backed integration tests in
rockbox-playback and rockbox-ffi, plus Ruby/Go/Elixir/Gleam suites.
Version bumps: rockbox-playback 0.3.0->0.4.0, rockbox-ffi 0.2.0->0.3.0,
python/ts/ruby/kotlin/clojure 0.4.0->0.5.0, elixir 0.5.0->0.6.0,
gleam 1.4.0->1.5.0, erlang nif 1.0.0->1.1.0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Gleam builds "Source" links against the `v<version>` git tag, but this
monorepo tags Gleam releases `gleam-v<version>` — so every source link in the
published v1.3.1 docs 404'd. Set `tag_prefix = "gleam-"` in gleam.toml's
[repository] (Gleam prepends it to the default `v<version>`), yielding the
correct `gleam-v<version>` ref.
Re-add `gleam-v*` to every build/release workflow's tags-ignore list so
creating/pushing the gleam-v<version> tag doesn't kick off platform builds
(kept alongside erlang-v*; elixir has no per-version tag — it links to master).
Verified: docs now emit blob/gleam-v1.4.0/bindings/gleam; all workflow YAML
parses.
Cover the two features that shipped across the FFI but were missing from
the docs: the codecs Decoder surface (open a file, pull interleaved-stereo
int16 PCM chunk-by-chunk) and the player stereo-balance control.
- New bindings/erlang.mdx (+ docs.json nav) documenting rockbox_ffi_nif as
the shared BEAM native layer under the Elixir/Gleam wrappers
- overview: Three surfaces -> Four (add Decoder + process-wide note), add
balance to the Player cards, add Erlang to the language picker
- Add "Decode a file to PCM" + balance to all 9 per-language pages
The Rocksky WebSocket client read `deviceId` from every server message, including
the `device_registered` broadcast carrying ANOTHER device's id when a new device
(e.g. a web/mobile miniplayer) joins. That overwrote its own id, so its
now-playing pushes were tagged with the wrong device and miniplayers mislabeled
the playback source.
Only adopt the id from the registration reply (status: "registered"); ignore
device_registered announcements.
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.
Progressive M4A/AAC playback re-frames MP4 samples to ADTS and decodes them
through the aac_bsf codec. Its update_playing_time() derives elapsed time as
(offset - first_frame_offset) * 8 / id3->bitrate, but the streaming shim
(rbcodec_open_stream) never sets id3->bitrate, so bitrate == 0 → integer
divide-by-zero → WASM "RuntimeError: divide by zero", killing playback of any
faststart-AAC track (and raw AAC radio) with a "stream error".
Guard the division: when bitrate is unknown (0) leave the elapsed indicator
untouched — the host tracks position from decoded PCM. Fix applied to both the
firmware source and the vendored rockbox-codecs copy.
Reproduced deterministically against the real failing track: without the guard,
the first decoded frame traps (bitrate=0, offset=935); with it, the whole file
decodes cleanly through the decode + DSP pipeline.
Faststart MP4/M4A files carrying plain AAC are now demuxed in the worker
and each container sample re-framed as a self-contained ADTS packet,
streamed through the same progressive rb_decode_packet path MP3 uses —
playback starts within ~a second with bounded memory instead of
downloading the whole file first. Also pre-demuxes the prefetched next
track to ADTS for gapless transitions. Non-faststart (moov last), ALAC,
and HE-AAC files fall back to the seekable whole-file path.
Prefetch the predicted next track's bytes into memory during the current
track's playback so an auto-advance decodes straight from RAM with no
network wait. Whole-file (drain) formats only — MP3/AAC already start fast
via progressive streaming. Shared playDecodedBytes() feeds both the drain
path and the prefetch fast-path; prefetch state is cleared on queue replace
and clearQueue to avoid decoding a stale track.
fetchCargoVendor bundles a normalized Cargo.lock into its output, so the
rockbox-codecs 0.2.1 / rockbox-playback 0.4.1 / rockbox-ffi 0.3.1 bumps
changed the vendored-deps hash. Update it so `nix build` stops failing
with a hash mismatch.
sha256-JaAKEAyXXSmJwU/DBJOkwOwkTtn0aQuQrdq4xx+ZFVY=
-> sha256-xp2nWa6nX2Q2tV8q1cpvPHEgHPMLaJB3PFI6CaFrRE8=
Propagate the aac_bsf/HE-AAC decode fix (rockbox-codecs 0.2.1) to every
language binding that wraps the librockbox_ffi C ABI:
clojure 0.5.0 -> 0.5.1 (build.clj default)
elixir 0.6.0 -> 0.6.1
erlang 1.1.0 -> 1.1.1 (app.src vsn; gleam.toml realigned 1.0.0 -> 1.1.1)
gleam 1.5.0 -> 1.5.1
kotlin 0.5.0 -> 0.5.1
python 0.5.0 -> 0.5.1
ruby 0.5.0 -> 0.5.1
typescript 0.5.0 -> 0.5.1
go and swift carry no in-repo version field (versioned by git tags), and
wasm (0.1.2) was already bumped with the fix commit.
`afmt_for_ext("aac")` mapped raw ADTS/HE-AAC streams (content-type
audio/aac / audio/aacp) to AFMT_MP4_AAC, whose libm4a demuxer needs
moov/mdat atoms and fails outright on a raw ADTS frame stream — HE-AAC
internet radio played silence. Map "aac" to AFMT_AAC_BSF (the bitstream
decoder), matching Rockbox's own get_afmt_from_content_type() and the
.aac file-extension table. .m4a/.mp4 keep AFMT_MP4_AAC (they use the
seekable/file route, not forward streaming).
Verified: the alt64 aacPlus stream now decodes at 44100 Hz (SBR active).
Bump the affected crate chain (patch): rockbox-codecs 0.2.1,
rockbox-playback 0.4.1, rockbox-ffi 0.3.1, and rockbox-wasm 0.1.2
(ships the recompiled core).
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).
Queue:
- Fix stale "n / m" counter — enqueue only emitted a `queue` event but the
UI renders status.queue_len; emitQueue() now also emits a status snapshot,
so "+ Queue" updates the count immediately.
- Queue panel in the example: track list (basename or now-playing title),
current-track highlight, click-to-play (skipTo), per-row remove, clear,
and export as .m3u8. Counter shows "– / n" when nothing is playing.
- removeAt(i) across worker/facade/typings with Rockbox semantics: removing
before current renumbers, removing current hard-cuts to the slide-in,
removing the playing tail stops (queue kept), last track stops.
- Rockbox insertion modes (apps/playlist.c): prepend / insert (chained after
the previous Insert batch, reset on track change) / play next / play last /
insert shuffled / last shuffled / replace / at-index. insert() on the
facade + InsertMode TS enum + a mode picker next to "+ Queue".
- Queue persists to localStorage via jotai (rb.queue): saved on every queue
event (guarded so the initial empty state can't wipe it), restored on boot
unless the boot action already set a queue.
M3U (plain JS — the queue is URLs): parseM3u / serializeM3u / isM3uUrl
statics, loadM3u(Url) / enqueueM3u(Url) / exportM3u on the player. Playing
an .m3u/.m3u8 URL loads the whole playlist; an Import button reads a local
playlist file; Export downloads the queue.
Icons: @tabler/icons-react replaces the emoji transport (play/pause, prev,
next, stop, shuffle, repeat with IconRepeatOnce for repeat-one, playlist
import/export/clear/remove, volume); shuffle/repeat highlight via the active
style when enabled.
Verified with headless Chrome against the Vite example: counter 1/1→1/2→1/3
live, panel rows track removals, 10 SVG icons render, shuffle highlights,
queue (3 tracks) survives reload and restores on boot, playback unaffected.
Root cause (found by driving headless Chrome against the real example and
instrumenting the served worker): emitPcm posted the PCM with a transfer
list and THEN read arr.length for the local accounting. postMessage transfer
detaches arr.buffer, so arr.length was already 0 — every chunk counted as
zero frames. Backpressure never engaged, the whole track "streamed" in a
couple of seconds, waitForDrain saw an empty queue, advanceAfterEnd stopped
the player, and its flush wiped the audio right as the delayed port messages
reached the worklet. The pre-crossfade code counted from procLen (a plain
number) before posting, which is why the crossfade refactor introduced this.
Fixes and hardening:
- emitPcm: read the frame count BEFORE the transfer.
- Queue accounting is now derived from monotonic counters
(wlQueued = postedFrames - consumed) instead of trusting the worklet's
point-in-time `queued` snapshot, which undercounts in-flight chunks.
- All wasm heap access goes through fresh views over the live buffer
(liveBuf/copyI16/readU32/writeU8) — cached Module.HEAPxx snapshots can
reference a detached buffer after ALLOW_MEMORY_GROWTH grows the heap
(a 4-minute AAC decodes to ~45 MB and grows it past the 64 MB initial).
Verified end-to-end with playwright-core driving system Chrome against the
Vite example and a real 10 MB M4A: plays continuously with elapsed counting,
crossfade off and Always.
Port the original pcmbuf crossfade (via its faithful extraction in
crates/rockbox-playback/src/crossfade.rs) into the decoder worker — no second
player needed: like pcmbuf, we mix at the PCM level into the single output
stream.
Worker:
- Q16 gains (MIXFADE_UNITY), Bresenham MixFader ramps, mixfade_sample
rounding and saturating clip16 — byte-faithful to apps/pcmbuf.c.
- Same parameters/semantics as the firmware: mode (off / auto track change /
manual skip / shuffle / shuffle-or-manual / always), fade-out delay 0-7 s +
duration 0-15 s, fade-in delay + duration, and fade-out mixmode
(crossfade = both fade, mix = outgoing stays full volume).
- Output pipeline: crossfade mixer -> tail holdback -> worklet. The newest
fade_out_delay+duration seconds are held back so a transition always has an
outgoing tail; natural end hands it to the next track (no flush, seamless),
manual skip fades from ~now (worklet high-water drops to 0.6 s when a
manual-capable mode is on so the response is quick).
- finalizeCrossfade plays the tail's fade out when the incoming track is
shorter than the region; per-track elapsed epoch (trackBase) keeps the
progress display sane across unflushed transitions.
API: setCrossfade(mode, {fadeOutDelay, fadeOutDuration, fadeInDelay,
fadeInDuration, mixMode}) with CrossfadeMode + CrossfadeMixMode exported as
TypeScript enums (raw ints also accepted); persisted and restored like the
other settings.
Example: Crossfade card in the DSP panel (mode, four fade sliders with
Rockbox ranges, mixmode), persisted via jotai.
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.
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.
- Persist every player/DSP setting to localStorage with jotai atomWithStorage
(src/settings.ts); DspPanel + App read/write the atoms, and applySettings()
pushes them to the player once the engine boots so audio matches the
restored UI on reload.
- Resolve rockbox-wasm to the local build via a Vite alias + tsconfig paths
instead of a package dependency: the example lives inside the package, and
bun can't install a file:.. / workspace:* dep that points at its own parent.
Add @types/node for the vite config.
Verified: `bun install` + `bun run build` (tsc + vite) succeed.
pause/flush were posted over the Worker→worklet PCM port, but the worklet
only handled them on its node port. On the PCM port they fell through to the
PCM handler (new Int16Array(undefined) → an empty chunk) and were dropped, so
pause never set the silence flag and stop never cleared the queue — playback
just kept draining the ~3 s look-ahead buffer before the command "took
effect".
Handle paused/flush in the worklet's PCM-port handler (_onPcm) alongside PCM.
Now pause silences within a render quantum and stop/seek/track-change flush
the queue immediately.
- rockbox-ffi/dsp.rs: add rb_dsp_set_crossfeed (headphone crossfeed) and
rb_dsp_set_pbe (Perceptual Bass Enhancement), wrapping the rockbox-dsp
crate's crossfeed + pbe APIs. Export both in scripts/build-wasm.sh.
- Facade: setCrossfeed / setPbe, persisted + restored; add a CrossfeedMode
enum (Off/Meier/Custom) so crossfeed mode is human-readable (like Repeat/
ReplayGain/Channel), setter still accepts the raw int.
- Example: new DspPanel.tsx exposes every DSP option — ReplayGain (mode +
preamp + noclip), bass/treble, EQ pre-cut, crossfeed, PBE, Haas surround,
compressor, channel mode + stereo width — all via the enums, no magic
numbers in the UI.
Verified: cargo check + `bun run build` (tsc + vite) pass; new exports present.
- Switch the example to bun (bun install / bun run dev); pre-hooks call
node scripts/copy-wasm.mjs directly so they're package-manager-agnostic.
- Untrack the accidentally-committed package-lock.json and
tsconfig.tsbuildinfo, and gitignore lockfiles + *.tsbuildinfo.
- Console (wasm:example) and docs updated to bun.
Verified: `bun run build` succeeds.
- copy-wasm.mjs used require.resolve("rockbox-wasm"), which throws
ERR_PACKAGE_PATH_NOT_EXPORTED against the package's import-only exports.
The example lives inside the package, so read dist/ from ../../dist directly.
- useRockbox INITIAL_STATUS.repeat was 0; StatusEvent.repeat is now the
RepeatMode enum → use RepeatMode.Off.
Verified: `npm run build` (tsc + vite) succeeds.
Turn the browser build into a distributable, friction-free npm package.
Single-threaded (no SharedArrayBuffer, no COOP/COEP):
- Build without -pthread; decode synchronously on the calling thread via
rb_decode_packet / rb_decode_file (no codec thread). This also removes the
entire Emscripten-proxy / park-state crash class we hit with pthreads.
- Worker → AudioWorklet PCM transport switched from a SAB ring to a
MessagePort (transferable ArrayBuffers). Worklet queues + plays, reports
consumed/queued frames back for backpressure + elapsed.
- Finite files: rb_meta_read_json + rb_decode_file (seekable). Live radio:
rb_decode_packet per ~32 KB segment. ICY StreamTitle metadata kept.
npm package (bindings/wasm, moved from web/):
- SINGLE_FILE embeds the wasm into rockbox-core.js — nothing to serve.
- package.json (repo/homepage/bugs), index.d.ts with real TS enums
(RepeatMode/ReplayGainMode/ChannelMode; setters also take ints), README,
scripts/build.sh + scripts/publish.sh (always builds first).
- baseUrl option + ?core= worker param so assets resolve anywhere.
Example (bindings/wasm/example): Vite + React + TypeScript + Tailwind v4,
depends on the package via "file:..", copies dist → public/rockbox.
Console: wasm:build / wasm:example / wasm:dev / wasm:publish. Removed the old
static dev server (Vite serves the demo). Docs: WEBASSEMBLY.md, CLAUDE.md,
bindings/README.md.
The per-segment rb_decoder_open approach spawned a codec pthread per 32 KB
and drove it with a blocking next_chunk on the module main thread. Both are
unsafe on an Emscripten module main thread: it can't reliably block on a
channel recv or on pthread_create, so a proxied stdout/growth call re-enters
during the wait → "inconsistent park state" panic, and the pthread spawns
recurse into a stack overflow.
Add synchronous, thread-free decode (the "decode function" requested):
- rockbox-codecs: decode_file_sync(path) and decode_bytes_sync(bytes, ext)
run rbcodec_run() to completion on the CALLING thread with a Vec-appending
sink — no thread spawned, nothing blocks on a channel.
- rockbox-ffi: rb_decode_file(path,…) and rb_decode_packet(data,len,ext,…) —
"bytes in, PCM out", caller frees with rb_buffer_free.
Live radio (worker): playLiveStream now decodes each ~32 KB network segment
in memory via rb_decode_packet (no MEMFS, no pthread), runs it through the
DSP/resampler, and streams the PCM into the ring with backpressure. Finite
files keep the incremental threaded Decoder (one pthread, works).
Per request, drop the codec-side push-streaming and do the stream loop in JS.
Revert the Rust streaming work: remove rockbox-ffi stream.rs
(rb_stream_*/rb_decoder_open_stream), rb_decoder_try_next_chunk, and
rockbox-codecs Decoder::poll_chunk. The WASM surface is back to the plain
file decoder + DSP + metadata.
Live radio (rockbox-decoder-worker.js): detect via Content-Length, then
playLiveStream() reads the network ReadableStream, slices it into ~32 KB
segments, and decodes each with a throwaway rb_decoder_open (MEMFS file),
forwarding PCM to the ring. next_chunk only ever blocks briefly (segment is
fully in memory), so the module main thread never parks for real time — no
crash, no stall. A ~2.5 s prebuffer rides out segment-boundary jitter;
underruns just play silence and recover.
ICY metadata: request Icy-MetaData:1, and if the server exposes icy-metaint
(CORS permitting), demux the interleaved metadata out of the audio and parse
StreamTitle → now-playing artist/title. Also read icy-name (station) and
icy-br (bitrate). Falls back silently when CORS blocks it.
UI: show StreamTitle / station for live, and FORMAT · kHz · kbps for every
track.
The push StreamReader returned a partial read whenever the buffer held
fewer bytes than the codec asked for. Rockbox codecs treat a short read as
end-of-stream, so the codec stopped at the first gap — the stream played
for a moment then ended and the queue "advanced" to stopped.
Make the reader fill the whole requested buffer, blocking (on the codec
thread) for the feed to top it up; only a genuinely closed stream returns a
short/zero read. The main thread still polls non-blocking, so this can't
deadlock.
Radio played ~3 s then aborted: `thread 'rbcodec' panicked … inconsistent
park state` during a memory-growth fd_write, with the worker parked inside
rb_decoder_next_chunk. Emscripten proxies memory growth to the module's main
thread; blocking it there (rx.recv) stalls the proxy and corrupts the codec
pthread. A realtime stream parks it for whole seconds, so it crashes; finite
files rarely block long enough to hit it.
Fix: never park the main thread — poll instead.
- rockbox-codecs: add Decoder::poll_chunk (try_recv → Poll).
- rockbox-ffi: add rb_decoder_try_next_chunk (state: 0 chunk / 1 pending /
2 ended).
- worker pump: use the non-blocking poll for every track; on `pending`,
reschedule and yield to the event loop (which also lets the live feed run).
Removes the now-unneeded stream pull-gate.
Before, the worker did `await resp.arrayBuffer()`, which never resolves for
an unbounded stream — live radio never started.
rockbox-ffi: add a push-based byte stream (crates/rockbox-ffi/src/stream.rs):
rb_stream_new/feed/close/available/free + rb_decoder_open_stream over
Decoder::open_stream. The reader blocks on an empty-but-open buffer (waits
for more) and only EOFs when closed — so a live stream plays forever and a
network stall becomes silence, not end-of-track. Teardown closes the stream
before freeing the decoder so the codec thread's join can't hang.
Worker: branch on Content-Length (+ icy-* headers). Finite files keep the
MEMFS whole-file path (metadata, duration, seeking); live streams push the
fetch ReadableStream into the blocking reader with a prebuffer, an
available>threshold pull-gate (so decode never parks the single worker
thread), and ~8 MB backpressure so memory stays bounded. UI shows a LIVE
badge and disables seeking for infinite streams.
Every control started `disabled` and enableUI() only ran after
player.init(), but init() only runs on the first Play click — which was
disabled. Nothing ever enabled the UI. Leave the URL input + Play/Queue
buttons enabled as the entry point (they boot the AudioContext on first
click under the required user gesture); enableUI() then unlocks the rest.
Also make boot single-flight + retry-safe and drop the misleading
"Initialising…" message.
The WASM target compiled the entire Rockbox firmware (netstream, playlist
engine, servers, SDL) via a C bridge. Now that the core is extracted into
rockbox-codecs / rockbox-dsp / rockbox-metadata behind the rockbox-ffi flat
C ABI, compile only those into WASM and let the browser be the player.
Rust:
- rockbox-ffi: gate the cpal-backed `player` module behind a default
`player` feature so it can build for wasm32-unknown-emscripten with
--no-default-features (decode + DSP + metadata only).
- Delete crates/wasm (the firmware bridge).
Build:
- Rewrite scripts/build-wasm.sh: `cargo rustc -p rockbox-ffi
--no-default-features --crate-type staticlib` then emcc link into
web/rockbox-core.{js,wasm} (~1.2 MB). No firmware configure/make, no
codec objcopy dance. -Wl,--no-check-features for the prebuilt std objects.
- Dev server COEP require-corp -> credentialless so cross-origin webfonts load.
Web example (three small JS files, player entirely in JS):
- rockbox.js: main-thread RockboxPlayer facade (AudioContext, GainNode
volume, events).
- rockbox-decoder-worker.js: owns the WASM module; queue/transport/decode/
DSP/resample off the main thread (Decoder blocks on a Condvar), writes PCM
into a SharedArrayBuffer ring.
- rockbox-audio-worklet.js: reads the ring -> speakers.
- index.html: simplified UI (42k -> 18k), keeps the full 10-band EQ + DSP
config; Outfit + Lexend + JetBrains Mono fonts.
Docs: rewrite WEBASSEMBLY.md, web/README.md, CLAUDE.md WASM section.
Add Android arm64-v8a + x86_64 librockbox_ffi.so to the Kotlin artifact so
an app depending on the Maven package gets the native lib packed into its APK.
- bindings-release.yml: new build-lib-android job cross-compiles
librockbox_ffi.so for aarch64/x86_64 via cargo-ndk + the runner's NDK
(--platform 26, clang/libclang for oboe-sys' bindgen). continue-on-error
like the BSD jobs; wired into the release job's needs so its .so land as
librockbox_ffi-android-{arm64,x64}.so release assets.
- fetch-libs.sh: download the android-{arm64,x64} .so and stage them at
bindings/kotlin/src/main/resources/lib/<abi>/librockbox_ffi.so — the path
AGP extracts JAR native libs from. --all covers them; --target android-*
is Kotlin-only.
- publish-kotlin.sh / build.gradle.kts / README: document the Android slices
(lib/<abi>/ for AGP; the desktop FFM loader is not used on Android).
Add two queue-editing operations to the rockbox-playback API and
propagate them through the C ABI and all language bindings.
Core (rockbox-playback):
- Player::remove(index) — delete the track at a 0-based queue position.
Removing before the cursor shifts it back (current keeps playing);
removing the current track hard-cuts to the track that slides in;
removing the last track stops playback; out-of-range is a no-op.
- Player::clear_queue() — empty the queue, stop playback, clear resume.
- New Command::Remove/Clear, engine methods, and a pure, unit-tested
perform_remove() model mirroring perform_insert().
C ABI (rockbox-ffi + include/rockbox_ffi.h):
- rb_player_remove(RbPlayer*, size_t index)
- rb_player_clear_queue(RbPlayer*)
Bindings: Python, TypeScript, Go, Ruby, Kotlin, Swift, Clojure, Erlang
(NIF), Elixir and Gleam all gain the two methods following each
binding's existing conventions, with tests/docs where present.
Tests: perform_remove unit tests, device-backed integration tests in
rockbox-playback and rockbox-ffi, plus Ruby/Go/Elixir/Gleam suites.
Version bumps: rockbox-playback 0.3.0->0.4.0, rockbox-ffi 0.2.0->0.3.0,
python/ts/ruby/kotlin/clojure 0.4.0->0.5.0, elixir 0.5.0->0.6.0,
gleam 1.4.0->1.5.0, erlang nif 1.0.0->1.1.0.
Gleam builds "Source" links against the `v<version>` git tag, but this
monorepo tags Gleam releases `gleam-v<version>` — so every source link in the
published v1.3.1 docs 404'd. Set `tag_prefix = "gleam-"` in gleam.toml's
[repository] (Gleam prepends it to the default `v<version>`), yielding the
correct `gleam-v<version>` ref.
Re-add `gleam-v*` to every build/release workflow's tags-ignore list so
creating/pushing the gleam-v<version> tag doesn't kick off platform builds
(kept alongside erlang-v*; elixir has no per-version tag — it links to master).
Verified: docs now emit blob/gleam-v1.4.0/bindings/gleam; all workflow YAML
parses.
Cover the two features that shipped across the FFI but were missing from
the docs: the codecs Decoder surface (open a file, pull interleaved-stereo
int16 PCM chunk-by-chunk) and the player stereo-balance control.
- New bindings/erlang.mdx (+ docs.json nav) documenting rockbox_ffi_nif as
the shared BEAM native layer under the Elixir/Gleam wrappers
- overview: Three surfaces -> Four (add Decoder + process-wide note), add
balance to the Player cards, add Erlang to the language picker
- Add "Decode a file to PCM" + balance to all 9 per-language pages