Always align markdown tables so that column pipes line up in raw text. Every cell in a column must be padded with trailing spaces to the width of the widest cell in that column. The separator row must use the same number of dashes as the column width. Example:
| Name | Role | Notes |
| ------- | --------- | ---------------------------- |
| Alice | Engineer | Owns the firmware layer |
| Bob | Designer | Works on the mobile UI |
| Charlie | QA | Runs integration test suites |When editing an existing table, re-align the whole table (not just the changed row). When adding a new table, align it before committing.
Rockbox Zig is a modern wrapper around the Rockbox open-source audio player firmware. It adds Rust/Zig services on top of the C firmware to expose gRPC, GraphQL, HTTP, and MPD APIs, a Typesense-backed search engine, Chromecast/AirPlay/Snapcast/Squeezelite output sinks, and a desktop/web UI.
The binary is called rockboxd. It is a single executable built by Zig that links:
- The Rockbox C firmware (compiled by Make into
build-lib/libfirmware.aand friends) - Rust crates (compiled by Cargo into
target/release/librockbox_cli.aandlibrockbox_server.a) - SDL2 for audio/event handling on the host platform
firmware/ Rockbox C firmware (audio engine, codecs, DSP)
apps/ Rockbox application layer (playlist, database, plugins)
lib/ Codec libraries (rbcodec, fixedpoint, skin_parser, tlsf)
build-lib/ Out-of-tree Make build directory (generated; do not edit)
build-headless/ Headless (no SDL) Make build directory for the embedded lib
crates/ Rust workspace
airplay/ ALAC encoder + RAOP/RTP sender (AirPlay 1 output)
slim/ Slim Protocol + HTTP broadcast server (Squeezelite multi-room output)
cli/ Entry point compiled to librockbox_cli.a (staticlib)
embed/ Embeddable desktop library (daemon boot + gRPC client C ABI)
server/ gRPC / HTTP server
settings/ load_settings() — reads settings.toml, applies sinks
sys/ FFI bindings to the C firmware (unsafe extern "C")
library/ Audio file scanning and SQLite library management
typesense/ Typesense client for fast music search
netstream/ HTTP streaming (Range-request based fd multiplexing)
chromecast/ Chromecast output
rpc/ gRPC definitions / generated code
graphql/ GraphQL schema and resolvers
mpd/ MPD protocol server
mpris/ MPRIS D-Bus integration
tracklist/ Playlist / tracklist management
types/ Shared Rust types
traits/ Shared Rust traits
zig/ Zig build script, main.zig (executable), lib.zig (embedded library)
include/ Public C header (rockboxd.h) for the embeddable library
gpui/ Desktop client (GPUI / Rust) — embeds daemon directly via librockboxd.a
expo/ React Native / Expo mobile app (see `expo/CLAUDE.md` rules below)
cd build-lib
make lib # builds libfirmware.a, librockbox.a, codec libsThe build-lib/ directory was pre-configured via Rockbox's tools/configure for the sdlapp target. Do not run configure again unless you know what you're doing — it regenerates the Makefile and overwrites any local edits.
cargo build --release -p rockbox-cli # produces target/release/librockbox_cli.a
cargo build --release -p rockbox-server # produces target/release/librockbox_server.aBoth crates have crate-type = ["staticlib"]. All transitive rlib dependencies are bundled into the .a.
cd zig
zig build # links everything into zig-out/bin/rockboxdcd build-lib && make lib && cd ..
cargo build --release -p rockbox-cli -p rockbox-server
cd zig && zig buildzig build lib produces zig/zig-out/lib/librockboxd.a — a fat archive that
any desktop GUI (GPUI, Swift/AppKit, Qt, …) can link against to embed the full
Rockbox daemon in-process. Uses the headless/cpal firmware (no SDL).
Public C header: include/rockboxd.h
Build order:
# 1. Headless firmware (no SDL)
cd build-headless && make lib && cd ..
# 2. Rust embed crate (daemon boot + gRPC client) + server
cargo build --release -p rockbox-embed -p rockbox-server
# 3. Fat static library
cd zig && zig build lib
# → zig-out/lib/librockboxd.aConsumers must also link these system libraries at final link time:
| Platform | Required flags |
|---|---|
| macOS | -framework CoreAudio -framework AudioUnit -framework AudioToolbox |
-framework CoreFoundation -framework Security |
|
| Linux | -lasound -lunwind -ldbus-1 |
The GPUI desktop client links librockboxd.a automatically via gpui/build.rs
and boots the daemon at startup — no external rockboxd process is needed.
# After building librockboxd.a (see above):
cd gpui && cargo build --releaseThe app boots the embedded daemon on launch (shows "Starting Rockbox…" while the gRPC server binds, then transitions to the full UI).
zig build only re-links if the .a files are newer than the binary. After changing C code, always run make lib first. After changing Rust code, run cargo build --release. If behavior doesn't match the code, check timestamps:
ls -la zig/zig-out/bin/rockboxd build-lib/libfirmware.a target/release/librockbox_cli.a- Linker args (raw flags): There is no
addLinkerArgmethod anywhere in Zig 0.16.0 — not onBuild.Step.Compileand not onBuild.Module. Passing raw flags like--allow-multiple-definitionthroughbuild.zigis not possible. Solve linker conflicts at the source level instead (e.g.objcopy --redefine-symin the build script). - Library/include paths:
exe.root_module.addLibraryPath(...),exe.root_module.addIncludePath(...). - System libraries:
exe.root_module.linkSystemLibrary("name", .{}). - Frameworks (macOS):
exe.root_module.linkFramework("Name", .{}). - Object/archive files:
exe.root_module.addObjectFile(b.path("..."))— used for both.oand.a. - Conditional linking: use
if (condition) { ... }around theaddObjectFile/linkSystemLibrarycalls directly inbuild()— there is no per-target feature-flag mechanism.
Settings file: ~/.config/rockbox.org/settings.toml
music_dir = "/path/to/Music"
# Audio output — pick one:
audio_output = "builtin" # SDL audio (default)
audio_output = "fifo"
fifo_path = "/tmp/snapfifo" # named FIFO for Snapcast; use "-" for stdout
audio_output = "airplay"
airplay_host = "192.168.1.x" # RAOP receiver IP
airplay_port = 5000 # optional, default 5000
audio_output = "squeezelite"
squeezelite_port = 3483 # optional, Slim Protocol port (default 3483)
squeezelite_http_port = 9999 # optional, HTTP PCM stream port (default 9999)Run one or more squeezelite clients pointing at rockboxd for multi-room:
squeezelite -s localhost -n "Living Room"
squeezelite -s localhost -n "Kitchen"The audio output abstraction lives in firmware/export/pcm_sink.h. Each sink implements struct pcm_sink_ops (init / postinit / set_freq / lock / unlock / play / stop).
| Enum constant | Value | Implementation file |
|---|---|---|
PCM_SINK_BUILTIN |
0 | firmware/target/hosted/sdl/pcm-sdl.c |
PCM_SINK_FIFO |
1 | firmware/target/hosted/pcm-fifo.c |
PCM_SINK_AIRPLAY |
2 | firmware/target/hosted/pcm-airplay.c |
PCM_SINK_SQUEEZELITE |
3 | firmware/target/hosted/pcm-squeezelite.c |
crates/settings/src/lib.rs:load_settings() reads audio_output and calls pcm::switch_sink().
Rust constants + helpers live in crates/sys/src/sound/pcm.rs.
- Pre-creates the named FIFO with
O_RDWR|O_NONBLOCKinpcm_fifo_set_path()then clearsO_NONBLOCK, so a permanent writer reference is held — readers never see premature EOF between tracks. sink_dma_stop()does NOT close the fd; it stays open across track transitions.- Startup order matters: rockboxd must start before snapserver. If snapserver opens the FIFO first it may get EOF and stop reading.
- On macOS, snapserver v0.35.0 ignores the
-ssample-format CLI flag; use/usr/local/etc/snapserver.conf:[stream] source = pipe:///tmp/snapfifo?name=default&sampleformat=44100:16:2
crates/airplay/implements the full RAOP stack in pure Rust (no tokio needed).alac.rs— ALAC escape/verbatim frame encoder: 352 stereo S16LE samples → 1411-byte bitstreamrtp.rs— RTP/UDP packet sender; RTCP NTP sync packets sent every ~44 framesrtsp.rs— synchronous RTSP client: ANNOUNCE (SDP) → SETUP → RECORD
pcm_airplay_connect()is called once persink_dma_start()(idempotent if already connected).- The
rockbox-airplayrlib must be force-included inlibrockbox_cli.avia theuse rockbox_airplay::_link_airplay as _shim incrates/cli/src/lib.rs.
crates/slim/implements a Slim Protocol TCP server and an HTTP PCM broadcast server, both in pure Rust.slimproto.rs— accepts squeezelite connections; sendsSTRM 's'pointing at the HTTP port; replies to everySTMtheartbeat withaudgto prevent squeezelite's 36-second watchdog from firing.http.rs— concurrent HTTP server (one thread per client); each client gets an independentBroadcastReceivercursor into the shared buffer, enabling true multi-room playback.lib.rs—BroadcastBuffer: sequence-numbered chunks, per-reader cursors, 4 MB cap with oldest-first eviction; lagging readers skip forward rather than blocking the writer.
firmware/target/hosted/pcm-squeezelite.cpaces the DMA loop to real time usingCLOCK_MONOTONIC. Useint64_tfor the nanosecond diff — unsigned subtraction wraps catastrophically whentv_nsecrolls over.- The
rockbox-slimrlib must be force-included viause rockbox_slim::_link_slim as _incrates/cli/src/lib.rs. - Slim Protocol framing: client→server is
opcode[4] + u32_t length BE + payload; server→client isu16_t length BE + opcode[4] + payload(length does NOT include the 2-byte length field itself). - ASCII-encoded PCM fields in STRM: squeezelite subtracts
'0'frompcm_sample_size,pcm_sample_rate,pcm_channels,pcm_endianness. Correct values:'1'(16-bit),'3'(44100 Hz),'2'(stereo),'1'(little-endian).
SDL_InitSubSystem(SDL_INIT_AUDIO) must be called explicitly on macOS because the SDL event thread (which normally does it) is #ifndef __APPLE__. This is done in firmware/target/hosted/sdl/system-sdl.c in the #else branch of the event-thread guard.
system-hosted.c installs a SIGTERM handler that loops forever (waits for SDL quit event). crates/cli/src/lib.rs overrides SIGTERM/SIGINT with a handler that kills the typesense child and calls _exit(0).
Spawned in crates/cli/src/lib.rs with Stdio::piped(). stdout/stderr lines are forwarded to tracing::debug!/tracing::warn! in background threads, keeping the PCM stdout stream clean in FIFO mode.
All Rust logging must use the tracing crate (tracing::debug!, tracing::info!, tracing::warn!, tracing::error!). Never use eprintln! or println! for diagnostic output in Rust code — they bypass the structured log filter, pollute stdout (breaking FIFO/pipe mode), and can't be silenced at runtime.
Severity guide:
tracing::error!— unrecoverable failures (connection refused, missing config)tracing::warn!— recoverable issues (non-fatal fallbacks, unexpected-but-handled states)tracing::info!— notable lifecycle events (session established, device paired)tracing::debug!— per-packet/per-frame detail, protocol negotiation steps
tracing is declared as a workspace dependency in the root Cargo.toml; add tracing = { workspace = true } to any crate that needs it. Control verbosity at runtime with RUST_LOG, e.g. RUST_LOG=debug rockboxd or RUST_LOG=rockbox_airplay=debug,info.
HTTP fds are encoded as values <= STREAM_HTTP_FD_BASE (-1000). stream_open/read/lseek/close in crates/netstream/ dispatch between HTTP and POSIX based on fd value. The global STREAMS map holds Arc<Mutex<StreamState>> per handle so concurrent reads don't serialize on a single lock.
- Create
firmware/target/hosted/pcm-<name>.c— model onpcm-fifo.c. - Add
PCM_SINK_<NAME>to the enum infirmware/export/pcm_sink.h. - Register
&<name>_pcm_sinkin thesinks[]array infirmware/pcm.c. - Add
target/hosted/pcm-<name>.cinside the#if PLATFORM_HOSTEDblock infirmware/SOURCES. - Add Rust constant
PCM_SINK_<NAME>: i32incrates/sys/src/sound/pcm.rs. - Add a
set_<name>_*wrapper if configuration is needed. - Handle in
crates/settings/src/lib.rs:load_settings(). - If the sink has a Rust implementation in a new crate: add a
_link_<name>()dummy fn and reference it fromcrates/cli/src/lib.rsto force inclusion in the staticlib.
A React Native client (Expo Router + NativeWind) lives in expo/. It mirrors
the GPUI desktop layout (gpui/src/ui/) — same dark palette, same
Spotify/Tidal-inspired information architecture: bottom-tab shell with a
persistent miniplayer, full-screen player modal, queue modal, and detail
screens for album / artist / playlist / genre. Most state is mock-only today;
real data should plug into the rockboxd gRPC / GraphQL client (crates/server/).
- Expo SDK 54 + expo-router for file-based routing (
app/). - NativeWind 4 with Tailwind 3 — class-based styling against a custom palette
declared in both
expo/tailwind.config.jsandexpo/constants/theme.ts(keep the two in sync). expo-image,expo-blur,expo-linear-gradient,@expo/vector-icons(Ionicons + MaterialCommunityIcons),react-native-safe-area-context.
expo/
├── app/
│ ├── _layout.tsx root stack, fonts, PlayerProvider, modals
│ ├── (tabs)/_layout.tsx custom tab bar with merged miniplayer dock
│ ├── (tabs)/{index,search,library}.tsx
│ ├── player.tsx, queue.tsx, settings.tsx
│ ├── album/[id].tsx, artist/[id].tsx, playlist/[id].tsx, genre/[id].tsx
│ └── playlist/new.tsx create regular OR smart (?mode=smart)
├── components/ mini-player, action-sheet, track-context-menu, …
├── lib/
│ ├── player-context.tsx single source of truth for playback state
│ ├── mock-data.ts ALBUMS / ARTISTS / PLAYLISTS / GENRES + helpers
│ └── nativewind-setup.ts cssInterop registrations (must be imported)
├── constants/theme.ts `Colors` palette consumed by inline styles
├── tailwind.config.js `Colors` palette mirrored as Tailwind tokens
├── babel.config.js babel-preset-expo + nativewind/babel
└── metro.config.js withNativeWind({ input: './global.css' })
- Always use
classNamefor styling. Inlinestyle={{...}}is reserved for values className genuinely cannot express:Animated.Valuebindings, runtime-computed widths (`${pct * 100}%`), per-instance shadow tokens, or colors derived from data (e.g.genre.color). - Never combine a function
style={(state) => ({...})}withclassNameon the same element. The functionstyleoverrides NativeWind's class output and silently drops every utility on that element. Use arbitrary-value classes (w-[48.5%],h-[100px],aspect-square) and theactive:variant for press feedback instead. - Static
style={{...}}objects (no callbacks) merge fine withclassName. - Color tokens live in
tailwind.config.jsunderbg.*,accent.*,text.*,border,divider,slider.*,danger. Reach for these (bg-bg-card,text-text-secondary,bg-accent) instead of hard-coded hex values. expo-image,expo-blur,expo-linear-gradient, and the safe-areaSafeAreaVieware wired up viacssInteropinlib/nativewind-setup.ts— any other third-party component needs to be registered there before it can acceptclassName.- Fonts:
font-sans→ SpaceGrotesk (UI),font-mono→ JetBrainsMono (durations / numerics). The TTFs are bundled via theexpo-fontplugin inapp.jsonand copied fromgpui/assets/fonts/.
lib/player-context.tsx holds queue, currentIdx, position (1 Hz tick),
isPlaying, shuffle, repeat, liked, userPlaylists, and the global track / entity
context-menu state. The mock advances position and auto-advances tracks; the
real implementation should replace the action handlers with rockboxd RPC calls
while keeping the same shape so the UI doesn't need to change.
cd expo
bun install # or npm/yarn
bun run start # iOS / Android / web via expo-router
bunx tsc --noEmit # type check
bunx expo lint # lint
bunx expo export --platform web # smoke-test the bundle (catches NativeWind transform issues)The mobile app talks to rockboxd through a native module that wraps a real tonic gRPC client written in Rust. It is split in two halves:
crates/expo/ — rockbox-expo Rust crate, staticlib + cdylib.
- Generates client-only proto bindings in
build.rsfrom the sharedcrates/rpc/prototree (linked in via theproto -> ../rpc/protosymlink inside the crate so we don't duplicate.protofiles). - Owns a single multi-thread Tokio runtime via
once_cell. - Exposes a flat C ABI (
rb_set_server_url,rb_ping,rb_play,rb_pause,rb_play_pause,rb_next,rb_prev,rb_seek,rb_status_json,rb_current_track_json,rb_like_track,rb_unlike_track,rb_free_string). Complex responses are returned as heap-allocated JSON C strings — caller MUST free viarb_free_string. Simple ops returni32status codes (0 = ok, <0 = error). - Deliberately does NOT depend on
rockbox-rpcto avoid pulling sqlx / typesense / library transitive deps that fight cross-compilation.
expo/modules/rockbox-rpc/ — Expo SDK 54 native module.
expo-module.config.jsondeclares iOS + Android module classes; the module is autolinked into the app viaexpo/package.json("rockbox-rpc": "file:./modules/rockbox-rpc").- iOS:
ios/RockboxRpcModule.swiftdeclares eachrb_*symbol with@_silgen_name(...)and exposes them throughFunction/AsyncFunction. The static library is delivered asios/RockboxExpo.xcframework(built byscripts/build-ios.sh); the.podspecvendored_frameworksit. - Android:
android/src/main/java/expo/modules/rockboxrpc/RockboxRpcModule.ktusesSystem.loadLibrary("rockbox_expo")+ JNIexternal fundeclarations. The.soper ABI is dropped intoandroid/src/main/jniLibs/<abi>/byscripts/build-android.sh(usescargo-ndk). - TS facade:
expo/modules/rockbox-rpc/src/index.tsdeclares the JS surface;expo/lib/rockbox-client.tsis the in-app helper with anisAvailableflag so callers can fall back to the mockPlayerProvideron web or when the libs haven't been built yet.
# iOS — produces expo/modules/rockbox-rpc/ios/RockboxExpo.xcframework
rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
cd expo/modules/rockbox-rpc
bun run build:ios
# Android — produces expo/modules/rockbox-rpc/android/src/main/jniLibs/<abi>/librockbox_expo.so
cargo install cargo-ndk
rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android
export ANDROID_NDK_HOME=... # NDK r25+
bun run build:androidAfter the native libs are in place, run bunx expo prebuild and then
bunx expo run:ios / run:android to bundle them into the app.
- Add a thin wrapper in
crates/expo/src/lib.rs(rb_<name>returningc_intfor unit ops or*mut c_charfor JSON-bearing reads). - Add the matching extern declaration in both
expo/modules/rockbox-rpc/ios/RockboxRpcModule.swiftandexpo/modules/rockbox-rpc/android/src/main/java/.../RockboxRpcModule.kt, plus anAsyncFunctionbinding. - Add the typed method to
expo/modules/rockbox-rpc/src/index.tsand the forwarding helper inexpo/lib/rockbox-client.ts. - Rebuild the native libs (
build:ios/build:android) —metrodoesn't pick up native changes automatically.
Server-streaming RPCs (StreamStatus, StreamCurrentTrack, StreamPlaylist)
are exposed as JS events — not async iterators — to play nicely with React's
render loop. The pipeline is:
tonic stream
→ tokio mpsc<String> (one queue per subscription, in crates/expo)
→ rb_poll_event(id, timeout_ms) -> *mut c_char
→ Swift dispatch_async / Kotlin Dispatchers.IO loop
→ sendEvent("rockbox.<topic>", payload) (Expo Modules EventEmitter)
→ RockboxRpc.addListener("rockbox.<topic>", cb)
Each subscribe* returns an opaque numeric subscription id; the JS facade in
expo/lib/rockbox-client.ts wraps that with an () => void unsubscribe
helper that removes both the event listener and the native subscription:
const unsubscribe = RockboxClient.subscribeStatus(
(s) => console.log("status", s.status),
(e) => console.warn("stream error", e.error),
);
// later: unsubscribe();Topics today: rockbox.status, rockbox.currentTrack, rockbox.playlist,
rockbox.library, rockbox.discovery (LAN mDNS / Bonjour scan via the
rockbox-discovery crate — emits one DiscoveredService per resolved peer),
plus rockbox.error for stream failures (carries subId, stream, error).
The subscribeDiscovery helper defaults to the _rockbox._tcp.local.
service; pass any other Bonjour service name (e.g. _googlecast._tcp.local.)
to scan for Chromecast / etc. Constants are also surfaced on the JS side via
RockboxClient.rockboxServiceName() and RockboxClient.chromecastServiceName().
To add a new streamed RPC: add a rb_subscribe_<name> in crates/expo/src/lib.rs
that follows the spawn_stream(...) pattern, declare the matching event topic
in the iOS / Android Events(...) lists, register a Function("subscribe<Name>")
in both modules, and add the typed subscribe<Name>(cb, onError?) helper to
expo/lib/rockbox-client.ts.
The Android build of librockbox_expo.so can host a full in-process
rockboxd: C firmware + codecs + Rust gRPC/HTTP/GraphQL/MPD servers + AAudio
sink + mDNS advertising. The phone becomes a symmetric peer of any LAN
rockboxd, while keeping the existing tonic gRPC client to control other peers.
Enable with --features embedded-daemon (the expo/modules/rockbox-rpc/scripts/build-android.sh
script does this by default). Without the feature the .so is the thin
~6 MB remote-only client; with it, ~48 MB.
PROFILE=release bash expo/modules/rockbox-rpc/scripts/build-android.shArchitecture (cdylib):
- Static-linked codecs (BINFMT_STATIC) —
lib/rbcodec/codecs/codecs.makerunsobjcopy --redefine-symper codec to make__header,codec_main,codec_run,codec_startdistinct symbols. Codec lookup goes throughlc_static_table[]infirmware/target/hosted/android/cdylib/lc-android.cinstead ofdlopen. - Per-target headless config:
firmware/export/config/androidcdylib.hdefinesCONFIG_BINFMT BINFMT_STATIC,CONFIG_PLATFORM (PLATFORM_HOSTED|PLATFORM_ANDROID),ROCKBOX_SERVER,CONFIG_SERVER, plus aDEBUGF debugfoverride so firmware diagnostics surface in logcat (debug-android.c routesdebugf→__android_log_print). - New cdylib-only sources under
firmware/target/hosted/android/cdylib/:system-android.c(boot + stdout/stderr→logcat shim),pcm-aaudio.c(AAudio sink),lc-android.c(codec table loader),rb_zig_compat.c(C compat layer for the 18rb_*symbolscrates/sysexpects from the Zig wrapper), plus stubslcd-noop.c,button-noop.c, etc. crates/expo/src/daemon.rswraps the firmware boot:rb_daemon_start(configDir, musicDir, deviceName)spawns a pthread that callsmain_c(), then waits up to 30s forcrates/server::start_servers()to bind gRPC :6061. Auto-runs an audio scan after gRPC binds (skipped if the library DB already has tracks; force withRockboxClient.rescanLibrary()orROCKBOX_UPDATE_LIBRARY=1).- The daemon module is referenced from the Expo module's
OnCreatelifecycle hook inRockboxRpcModule.kt, so the daemon boots at app launch and the process stays alive via the foregroundNowPlayingService.
Permissions / paths:
MANAGE_EXTERNAL_STORAGEdeclared inexpo/android/app/src/main/AndroidManifest.xml— required so the filesystem-based scanner can read/storage/emulated/0/Musicon API 33+.READ_MEDIA_AUDIOdoesn't help (it only grants MediaStore queries). TheuseAllFilesAccessPrompt()hook inexpo/app/_layout.tsxopens system Settings → "All files access" the first time the user runs the app.- The daemon sets
ROCKBOX_LIBRARY=<musicDir>env var (canonical, read bycrates/{settings,server,graphql}); previous builds set the misnomerROCKBOX_MUSIC_DIRwhich nothing read. firmware/target/hosted/android/debug-android.croutes firmwareprintf/fprintfand DEBUGF to logcat under tagRockbox.system-android.c::redirect_stdio_to_logcatadds a pthread that pipes stdout/stderr fds to__android_log_writeso even rawprintfcalls (the[metadata]/[streamfd]chatter Rockbox emits) are visible.
JS-callable controls (in addition to the remote-only surface):
RockboxClient.rescanLibrary()— force a full audio scanRockboxClient.hasAllFilesAccess()/requestAllFilesAccess()— Android permission gatingRockboxNowPlaying.start()— early foreground-service promotion (so the process survives backgrounding while the daemon is running)
Common pitfalls (see auto-memory):
pcm_sink::set_freqreceives an INDEX intohw_freq_sampr[], not Hz — AAudio gets opened at "4 Hz", silently falls back to 48 kHz, 44.1 kHz audio plays ~9 % fast (chipmunk effect). Look up the rate first.apps/codecs.c::ci(struct) collides with each codec'scodec_crt0.c::ci(pointer) at link time. Firmware-side rename tofirmware_cikeeps the type/size invariants distinct.apps/main.cgatesserver_init()onROCKBOX_SERVERbutapps/SOURCESgates the .c COMPILATION onCONFIG_SERVER— define BOTH.- Android 14+ blocks
startForegroundServicefrom background process state even withmediaPlaybacktype.startServiceCompatchecks importance before promoting;refreshNotificationdoes the same beforestartForeground.
See crates/expo/README.md for the full architecture writeup.
The WASM target compiles the Rockbox C firmware + a thin Rust shim
(crates/wasm/) into web/rockboxd.{js,wasm} via Emscripten.
Build with bash scripts/build-wasm.sh; serve web/ with
node scripts/wasm-dev-server.mjs (COOP/COEP headers required for
SharedArrayBuffer).
Every #[no_mangle] pub extern "C" function in crates/wasm/src/lib.rs
that JavaScript needs to call must appear in the EXPORTED_FUNCTIONS
list in scripts/build-wasm.sh. Emscripten only creates the
Module._functionName JS wrapper for listed symbols; missing entries are
silently dead-stripped and Module._rb_foo evaluates to undefined at
runtime. rockbox.js guards every call with:
const fn = this._mod[`_${name}`];
if (typeof fn !== 'function') return; // export absent in this buildso a missing export fails silently — no error, no effect. The checklist for adding a new export:
- Implement
rb_<name>incrates/wasm/src/lib.rswith#[no_mangle]. - Add
"_rb_<name>"toEXPORTED_FUNCTIONSinscripts/build-wasm.sh. - Add the JS-side wrapper in
web/rockbox.js. - Rebuild (
bash scripts/build-wasm.sh) — a Rust-only recompile is not enough; the emcc link step must re-run to pick up the new export.
Rockbox's DSP layer stores all gain and Q values in tenths, not whole units. Passing plain integers silently produces 10× weaker effects:
| Field | Unit | Example |
|---|---|---|
eq_band_setting.gain |
dB × 10 | −80 = −8.0 dB |
eq_band_setting.q |
Q × 10 | 70 = Q 7.0 |
eq_band_setting.cutoff |
Hz (raw) | 4000 = 4 kHz |
dsp_set_eq_precut(precut) |
tenths of dB | 120 = 12.0 dB |
filter_pk_coefs / filter_ls_coefs / filter_hs_coefs in
lib/rbcodec/dsp/dsp_filter.c document this:
"Q factor value multiplied by ten" / "decibel value multiplied by ten".
In web/rockbox.js, setEqBand multiplies the slider gain (plain dB)
by 10 before calling rb_set_eq_band:
this._call('rb_set_eq_band', [b, cutoff | 0, q | 0, (gain | 0) * 10]);-
EQ / replaygain: call
dsp_set_eq_coefs()/replaygain_update()directly in thewasm_cmdthread handler. New coefficients take effect for all audio decoded after the call. The pre-decoded audio in pcmbuf (~3 s for the WASM build) plays out with the old settings first — a smooth, cut-free transition. Do not postQ_AUDIO_REMAKE_AUDIO_BUFFERfor EQ changes. REMAKE callspcmbuf_play_stop()which causes an audible cut and codec seek on every slider move. -
Crossfade: changing the mode requires resizing the pcmbuf (the crossfade window changes the required buffer size). Always call
pcmbuf_request_crossfade_enable(mode)from the handler, then postQ_AUDIO_REMAKE_AUDIO_BUFFERwhen audio is playing so the audio thread callspcmbuf_init()→pcmbuf_finish_crossfade_enable()with the new size. When stopped, skip the REMAKE — the nextpcmbuf_init()at track-start will pick upcrossfade_enable_requestautomatically.
# Run the daemon
./zig/zig-out/bin/rockboxd
# Run with AirPlay debug logging
RUST_LOG=debug ./zig/zig-out/bin/rockboxd
# Test FIFO → stdout pipe
./zig/zig-out/bin/rockboxd | ffplay -f s16le -ar 44100 -ac 2 -
# Check binary vs library timestamps
ls -la zig/zig-out/bin/rockboxd build-lib/libfirmware.a target/release/librockbox_cli.a
# Verify AirPlay symbols are present
nm zig/zig-out/bin/rockboxd | grep pcm_airplay
# Verify squeezelite symbols are present
nm zig/zig-out/bin/rockboxd | grep pcm_squeezelite
# Verify a crate is in the staticlib
ar t target/release/librockbox_cli.a | grep airplay
ar t target/release/librockbox_cli.a | grep slim
# Multi-room squeezelite test
squeezelite -s localhost -n "Room 1"
squeezelite -s localhost -n "Room 2"