Rockbox open source high quality audio player as a Music Player Daemon
mpris rockbox mpd libadwaita audio rust zig deno
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add SNAPCAST.md documenting FIFO sink

+408
+408
SNAPCAST.md
··· 1 + # Snapcast / FIFO PCM Sink 2 + 3 + This document traces every hop an audio frame takes from the Rockbox C firmware 4 + through the FIFO PCM sink to a Snapcast server (or any other pipe consumer). 5 + 6 + --- 7 + 8 + ## Table of contents 9 + 10 + 1. [Overview](#overview) 11 + 2. [Layer map](#layer-map) 12 + 3. [PCM sink vtable (`pcm-fifo.c`)](#pcm-sink-vtable-pcm-fifoc) 13 + 4. [The DMA thread](#the-dma-thread) 14 + 5. [FIFO pre-open strategy](#fifo-pre-open-strategy) 15 + 6. [stdout mode](#stdout-mode) 16 + 7. [Track transitions and EOF prevention](#track-transitions-and-eof-prevention) 17 + 8. [FFI boundary (`crates/sys`)](#ffi-boundary-cratessys) 18 + 9. [Settings and startup (`crates/settings`)](#settings-and-startup-cratessettings) 19 + 10. [Snapcast integration](#snapcast-integration) 20 + 11. [Startup order](#startup-order) 21 + 12. [Snapserver configuration (macOS)](#snapserver-configuration-macos) 22 + 13. [Other pipe consumers](#other-pipe-consumers) 23 + 14. [Gotchas and known limits](#gotchas-and-known-limits) 24 + 25 + --- 26 + 27 + ## Overview 28 + 29 + The FIFO sink writes raw **S16LE stereo PCM at 44100 Hz** to either a named 30 + FIFO (pipe) or stdout. Its primary use case is feeding 31 + [Snapcast](https://github.com/badaix/snapcast) for synchronized multi-room 32 + playback, but any consumer that reads a raw PCM stream works — `ffplay`, 33 + `aplay`, `sox`, custom scripts, etc. 34 + 35 + There is no Rust crate involved. This is a pure-C PCM sink with a thin Rust 36 + FFI wrapper for configuration. 37 + 38 + --- 39 + 40 + ## Layer map 41 + 42 + ``` 43 + ┌────────────────────────────────────────────────────────┐ 44 + │ Rockbox C firmware (pcm.c, audio thread) │ 45 + │ pcm_play_data() → sink.ops.play() │ 46 + │ pcm_play_dma_complete_callback() per chunk │ 47 + └───────────────────┬────────────────────────────────────┘ 48 + │ raw S16LE stereo PCM chunks 49 + ┌───────────────────▼────────────────────────────────────┐ 50 + │ firmware/target/hosted/pcm-fifo.c │ 51 + │ pcm_fifo_set_path() — pre-creates FIFO, opens fd │ 52 + │ sink_dma_start() — spawns fifo_thread │ 53 + │ fifo_thread() — blocking write() loop │ 54 + │ sink_dma_stop() — signals thread, keeps fd │ 55 + └───────────────────┬────────────────────────────────────┘ 56 + │ blocking write() to FIFO or stdout 57 + ┌───────────────────▼────────────────────────────────────┐ 58 + │ Named FIFO (/tmp/snapfifo) or stdout │ 59 + └───────────────────┬────────────────────────────────────┘ 60 + │ read() 61 + ┌───────────────────▼────────────────────────────────────┐ 62 + │ snapserver (pipe:// source) │ 63 + │ — or — │ 64 + │ ffplay / aplay / custom consumer │ 65 + └────────────────────────────────────────────────────────┘ 66 + ``` 67 + 68 + --- 69 + 70 + ## PCM sink vtable (`pcm-fifo.c`) 71 + 72 + `firmware/target/hosted/pcm-fifo.c` implements `struct pcm_sink` with the 73 + following vtable: 74 + 75 + | Op | Implementation | 76 + |-------------------|-------------------------------------------------------------| 77 + | `init` | `pthread_mutex_init` (recursive) | 78 + | `postinit` | no-op | 79 + | `set_freq` | no-op (output is always 44100 Hz; snapserver must match) | 80 + | `lock` / `unlock` | `pthread_mutex_lock/unlock` | 81 + | `play` | `sink_dma_start` — opens fd if needed, spawns `fifo_thread` | 82 + | `stop` | `sink_dma_stop` — signals thread, joins; keeps fd open | 83 + 84 + `fifo_pcm_sink` is registered at index `PCM_SINK_FIFO = 1` in the `sinks[]` 85 + array in `firmware/pcm.c`. 86 + 87 + --- 88 + 89 + ## The DMA thread 90 + 91 + `sink_dma_start(addr, size)` stores the initial PCM pointer/length under the 92 + mutex, then spawns `fifo_thread`. The thread mimics a hardware DMA interrupt 93 + loop: 94 + 95 + ``` 96 + while not stopped: 97 + 1. lock → grab (data, size) → clear pcm_data/pcm_size → unlock 98 + 2. if data: 99 + while size > 0 and not stopped: 100 + n = write(fifo_fd, data, size) 101 + handle EINTR/EAGAIN (retry) 102 + advance data pointer, decrement size 103 + 3. lock → pcm_play_dma_complete_callback(OK, &pcm_data, &pcm_size) → unlock 104 + 4. if no more data: break 105 + 5. pcm_play_dma_status_callback(STARTED) ← tells audio engine chunk consumed 106 + ``` 107 + 108 + The inner write loop handles partial writes and `EINTR`/`EAGAIN` correctly, 109 + advancing the pointer on each successful `write()` call. Pacing comes 110 + naturally from the blocking FIFO write — the kernel suspends the thread until 111 + the reader drains data, keeping throughput locked to the consumer's read rate. 112 + 113 + --- 114 + 115 + ## FIFO pre-open strategy 116 + 117 + `pcm_fifo_set_path(path)` is called once at startup from Rust settings code. 118 + It does two things: 119 + 120 + ### 1. Create the FIFO 121 + 122 + ```c 123 + mkfifo(path, 0666); // EEXIST is ignored 124 + ``` 125 + 126 + This ensures the filesystem entry exists before snapserver starts, so 127 + snapserver's `open()` can succeed immediately. 128 + 129 + ### 2. Open with a permanent writer reference 130 + 131 + ```c 132 + fd = open(path, O_RDWR | O_NONBLOCK); 133 + // then clear O_NONBLOCK: 134 + flags = fcntl(fd, F_GETFL); 135 + fcntl(fd, F_SETFL, flags & ~O_NONBLOCK); 136 + ``` 137 + 138 + **Why `O_RDWR`?** On Linux and macOS, opening a FIFO `O_WRONLY` blocks until 139 + a reader is present. `O_RDWR` succeeds immediately even with no reader, and 140 + critically it keeps the FIFO's open-writer-count at ≥1 for the entire lifetime 141 + of the process. This means: 142 + 143 + - A reader that comes and goes (snapserver restart, client reconnect) never 144 + causes the FIFO writer to receive `EPIPE` or the reader to see premature EOF. 145 + - Between tracks, when `fifo_thread` exits and `fifo_fd` is not closed, 146 + snapserver's reader stays connected and continues to block-read cleanly. 147 + 148 + **Why clear `O_NONBLOCK`?** After pre-opening, writes must block when the 149 + kernel pipe buffer is full, providing natural back-pressure / pacing. If 150 + `O_NONBLOCK` were left set, writes would return `EAGAIN` when the consumer is 151 + slow, corrupting the stream. 152 + 153 + --- 154 + 155 + ## stdout mode 156 + 157 + When `fifo_path = "-"`, the sink writes to stdout. This enables piping: 158 + 159 + ```sh 160 + rockboxd | ffplay -f s16le -ar 44100 -ac 2 - 161 + ``` 162 + 163 + Because Rockbox C code internally uses `printf()`/`puts()` on fd 1, stdout 164 + mode redirects fd 1 to stderr before any PCM is written: 165 + 166 + ```c 167 + static void redirect_stdout_to_stderr(void) 168 + { 169 + stdout_pcm_fd = dup(STDOUT_FILENO); // save real stdout 170 + dup2(STDERR_FILENO, STDOUT_FILENO); // fd 1 → stderr 171 + } 172 + ``` 173 + 174 + All subsequent `printf()` output goes to stderr (visible in the terminal but 175 + not in the pipe), while PCM writes go to `stdout_pcm_fd` — the saved copy of 176 + the original stdout. The PCM stream is never polluted by log output. 177 + 178 + This works only if `redirect_stdout_to_stderr()` is called before any C code 179 + writes to fd 1. `pcm_fifo_set_path("-")` calls it at startup, which is before 180 + any audio decoding begins. 181 + 182 + --- 183 + 184 + ## Track transitions and EOF prevention 185 + 186 + `sink_dma_stop()` **does not close `fifo_fd`**: 187 + 188 + ```c 189 + static void sink_dma_stop(void) 190 + { 191 + fifo_stop = true; 192 + if (fifo_running) { 193 + pthread_join(fifo_tid, NULL); 194 + fifo_running = false; 195 + } 196 + // fifo_fd intentionally left open 197 + } 198 + ``` 199 + 200 + On POSIX, a named FIFO's read end sees EOF only when all write-side file 201 + descriptors are closed. By keeping `fifo_fd` open across track boundaries, 202 + the consumer (snapserver) sees a continuous stream with no gaps. It never has 203 + to reconnect or re-open the pipe. 204 + 205 + The only time `fifo_fd` is closed is if `pcm_fifo_set_path()` is called again 206 + with a new path — an operation that doesn't happen at runtime. 207 + 208 + --- 209 + 210 + ## FFI boundary (`crates/sys`) 211 + 212 + `crates/sys/src/lib.rs` declares the C function: 213 + 214 + ```rust 215 + extern "C" { 216 + pub fn pcm_fifo_set_path(path: *const c_char); 217 + } 218 + ``` 219 + 220 + `crates/sys/src/sound/pcm.rs` wraps it safely: 221 + 222 + ```rust 223 + pub fn fifo_set_path(path: &str) { 224 + let cpath = CString::new(path).expect("path must not contain null bytes"); 225 + unsafe { crate::pcm_fifo_set_path(cpath.as_ptr()) } 226 + std::mem::forget(cpath); // C code only reads during init — leaking is fine 227 + } 228 + ``` 229 + 230 + `std::mem::forget` is used because `pcm_fifo_set_path` stores a raw pointer to 231 + the string and reads it later (e.g. in `sink_dma_start`'s fallback path). 232 + Dropping the `CString` would free the memory while C still holds a dangling 233 + pointer. Since this is a one-time startup call, leaking is acceptable. 234 + 235 + `pcm_switch_sink(PCM_SINK_FIFO)` switches the active sink. This is also an 236 + `extern "C"` call wrapped in `pcm::switch_sink(sink: i32) -> bool`. 237 + 238 + --- 239 + 240 + ## Settings and startup (`crates/settings`) 241 + 242 + `crates/settings/src/lib.rs:load_settings()` reads 243 + `~/.config/rockbox.org/settings.toml` and handles the FIFO case: 244 + 245 + ```rust 246 + Some("fifo") => { 247 + let path = settings.fifo_path.as_deref().unwrap_or("/tmp/rockbox.fifo"); 248 + pcm::fifo_set_path(path); 249 + pcm::switch_sink(pcm::PCM_SINK_FIFO); 250 + tracing::info!("audio output: fifo ({})", path); 251 + } 252 + ``` 253 + 254 + Relevant `settings.toml` keys: 255 + 256 + | Key | Type | Default | Description | 257 + |----------------|--------|-----------------------|---------------------------------------| 258 + | `audio_output` | string | `"builtin"` | Set to `"fifo"` to activate this sink | 259 + | `fifo_path` | string | `"/tmp/rockbox.fifo"` | FIFO path, or `"-"` for stdout | 260 + 261 + --- 262 + 263 + ## Snapcast integration 264 + 265 + [Snapcast](https://github.com/badaix/snapcast) is a synchronised multi-room 266 + audio system. snapserver reads a PCM source and distributes it to one or more 267 + snapclient instances with sub-millisecond synchronisation. 268 + 269 + The FIFO sink is designed to feed snapserver's `pipe://` source type. Once 270 + configured, the stream looks like: 271 + 272 + ``` 273 + rockboxd ──write()──▶ /tmp/snapfifo ──read()──▶ snapserver 274 + 275 + ┌──────────┴──────────┐ 276 + ▼ ▼ 277 + snapclient snapclient 278 + (living room) (kitchen) 279 + ``` 280 + 281 + ### snapserver configuration 282 + 283 + Add a stream source to `/etc/snapserver.conf` (or 284 + `/usr/local/etc/snapserver.conf` on macOS): 285 + 286 + ```ini 287 + [stream] 288 + source = pipe:///tmp/snapfifo?name=default&sampleformat=44100:16:2 289 + ``` 290 + 291 + The `sampleformat=44100:16:2` parameter is required on snapserver v0.35+. 292 + The `-s` CLI flag is **ignored** on macOS; it must be set in the config file. 293 + 294 + Start snapserver after rockboxd is running (see startup order below): 295 + 296 + ```sh 297 + snapserver 298 + ``` 299 + 300 + Connect clients: 301 + 302 + ```sh 303 + snapclient --host localhost --player default 304 + ``` 305 + 306 + --- 307 + 308 + ## Startup order 309 + 310 + **rockboxd must start before snapserver.** 311 + 312 + If snapserver opens the FIFO first (before rockboxd calls `pcm_fifo_set_path` 313 + which does the `O_RDWR` open), it gets the sole writer reference. When 314 + snapserver later closes its end, the FIFO appears to have no writers and 315 + subsequent readers see immediate EOF. 316 + 317 + Correct order: 318 + 319 + ``` 320 + 1. rockboxd starts → pcm_fifo_set_path() → FIFO created, O_RDWR fd held 321 + 2. snapserver starts → opens FIFO O_RDONLY → blocks until data flows 322 + 3. Playback begins → fifo_thread writes → snapserver reads → clients play 323 + ``` 324 + 325 + --- 326 + 327 + ## Snapserver configuration (macOS) 328 + 329 + On macOS, snapserver's `-s` (stream source) command-line flag is silently 330 + ignored. The only way to configure the source is via the config file: 331 + 332 + ```ini 333 + # /usr/local/etc/snapserver.conf 334 + [stream] 335 + source = pipe:///tmp/snapfifo?name=default&sampleformat=44100:16:2 336 + ``` 337 + 338 + Verify snapserver is reading it: 339 + 340 + ```sh 341 + snapserver --config /usr/local/etc/snapserver.conf 342 + ``` 343 + 344 + --- 345 + 346 + ## Other pipe consumers 347 + 348 + Since the FIFO carries raw S16LE stereo 44100 Hz PCM, it works with any tool 349 + that accepts that format: 350 + 351 + ```sh 352 + # Play directly with ffplay 353 + rockboxd | ffplay -f s16le -ar 44100 -ac 2 - 354 + 355 + # Encode on the fly with ffmpeg 356 + rockboxd | ffmpeg -f s16le -ar 44100 -ac 2 -i - output.mp3 357 + 358 + # Play with sox 359 + rockboxd | play -t raw -r 44100 -e signed -b 16 -c 2 - 360 + 361 + # Inspect levels with aplay (Linux) 362 + rockboxd | aplay -f S16_LE -r 44100 -c 2 363 + ``` 364 + 365 + All of these require `fifo_path = "-"` in `settings.toml` so rockboxd writes 366 + to stdout. 367 + 368 + --- 369 + 370 + ## Gotchas and known limits 371 + 372 + ### 1. Startup order is critical 373 + 374 + As described above, rockboxd must open the FIFO before snapserver. If 375 + snapserver opens it first and later closes its write end, snapclient may see 376 + EOF and stop buffering. Restart snapserver after rockboxd in that case. 377 + 378 + ### 2. Fixed 44100 Hz, S16LE stereo 379 + 380 + The FIFO sink does not resample. The `set_freq` op is a no-op. If Rockbox 381 + decodes a 48 kHz or 96 kHz track, the firmware resamples it internally to 382 + the codec's sample rate, but the PCM output is always delivered to the sink 383 + at 44100 Hz. Configure snapserver and any other consumer to match. 384 + 385 + ### 3. No volume control through the sink 386 + 387 + Volume is applied by the Rockbox DSP pipeline before PCM reaches the sink. 388 + The FIFO sink itself has no volume knob. Adjust volume through the Rockbox 389 + API or client applications. 390 + 391 + ### 4. Consumer back-pressure controls playback speed 392 + 393 + Because `fifo_fd` is in blocking mode, a slow or stalled consumer will cause 394 + `write()` to block, which stalls `fifo_thread`, which eventually stalls the 395 + DMA callback loop, which pauses decoding. This is correct behavior for 396 + synchronized output, but it means a crashed or frozen snapserver will freeze 397 + playback. Restart snapserver to recover. 398 + 399 + ### 5. macOS `snapserver.conf` vs CLI flag 400 + 401 + The `-s` flag to snapserver is silently ignored on macOS (at least v0.35.0). 402 + Always use the config file. See [Snapserver configuration (macOS)](#snapserver-configuration-macos). 403 + 404 + ### 6. Logging uses `tracing`, never `println!` 405 + 406 + All Rust-side diagnostic output must go through `tracing`. `println!` and 407 + `eprintln!` bypass the log filter and — in stdout mode — corrupt the PCM 408 + stream. Use `RUST_LOG=debug rockboxd` to see debug output on stderr.