···11+# Snapcast / FIFO PCM Sink
22+33+This document traces every hop an audio frame takes from the Rockbox C firmware
44+through the FIFO PCM sink to a Snapcast server (or any other pipe consumer).
55+66+---
77+88+## Table of contents
99+1010+1. [Overview](#overview)
1111+2. [Layer map](#layer-map)
1212+3. [PCM sink vtable (`pcm-fifo.c`)](#pcm-sink-vtable-pcm-fifoc)
1313+4. [The DMA thread](#the-dma-thread)
1414+5. [FIFO pre-open strategy](#fifo-pre-open-strategy)
1515+6. [stdout mode](#stdout-mode)
1616+7. [Track transitions and EOF prevention](#track-transitions-and-eof-prevention)
1717+8. [FFI boundary (`crates/sys`)](#ffi-boundary-cratessys)
1818+9. [Settings and startup (`crates/settings`)](#settings-and-startup-cratessettings)
1919+10. [Snapcast integration](#snapcast-integration)
2020+11. [Startup order](#startup-order)
2121+12. [Snapserver configuration (macOS)](#snapserver-configuration-macos)
2222+13. [Other pipe consumers](#other-pipe-consumers)
2323+14. [Gotchas and known limits](#gotchas-and-known-limits)
2424+2525+---
2626+2727+## Overview
2828+2929+The FIFO sink writes raw **S16LE stereo PCM at 44100 Hz** to either a named
3030+FIFO (pipe) or stdout. Its primary use case is feeding
3131+[Snapcast](https://github.com/badaix/snapcast) for synchronized multi-room
3232+playback, but any consumer that reads a raw PCM stream works — `ffplay`,
3333+`aplay`, `sox`, custom scripts, etc.
3434+3535+There is no Rust crate involved. This is a pure-C PCM sink with a thin Rust
3636+FFI wrapper for configuration.
3737+3838+---
3939+4040+## Layer map
4141+4242+```
4343+┌────────────────────────────────────────────────────────┐
4444+│ Rockbox C firmware (pcm.c, audio thread) │
4545+│ pcm_play_data() → sink.ops.play() │
4646+│ pcm_play_dma_complete_callback() per chunk │
4747+└───────────────────┬────────────────────────────────────┘
4848+ │ raw S16LE stereo PCM chunks
4949+┌───────────────────▼────────────────────────────────────┐
5050+│ firmware/target/hosted/pcm-fifo.c │
5151+│ pcm_fifo_set_path() — pre-creates FIFO, opens fd │
5252+│ sink_dma_start() — spawns fifo_thread │
5353+│ fifo_thread() — blocking write() loop │
5454+│ sink_dma_stop() — signals thread, keeps fd │
5555+└───────────────────┬────────────────────────────────────┘
5656+ │ blocking write() to FIFO or stdout
5757+┌───────────────────▼────────────────────────────────────┐
5858+│ Named FIFO (/tmp/snapfifo) or stdout │
5959+└───────────────────┬────────────────────────────────────┘
6060+ │ read()
6161+┌───────────────────▼────────────────────────────────────┐
6262+│ snapserver (pipe:// source) │
6363+│ — or — │
6464+│ ffplay / aplay / custom consumer │
6565+└────────────────────────────────────────────────────────┘
6666+```
6767+6868+---
6969+7070+## PCM sink vtable (`pcm-fifo.c`)
7171+7272+`firmware/target/hosted/pcm-fifo.c` implements `struct pcm_sink` with the
7373+following vtable:
7474+7575+| Op | Implementation |
7676+|-------------------|-------------------------------------------------------------|
7777+| `init` | `pthread_mutex_init` (recursive) |
7878+| `postinit` | no-op |
7979+| `set_freq` | no-op (output is always 44100 Hz; snapserver must match) |
8080+| `lock` / `unlock` | `pthread_mutex_lock/unlock` |
8181+| `play` | `sink_dma_start` — opens fd if needed, spawns `fifo_thread` |
8282+| `stop` | `sink_dma_stop` — signals thread, joins; keeps fd open |
8383+8484+`fifo_pcm_sink` is registered at index `PCM_SINK_FIFO = 1` in the `sinks[]`
8585+array in `firmware/pcm.c`.
8686+8787+---
8888+8989+## The DMA thread
9090+9191+`sink_dma_start(addr, size)` stores the initial PCM pointer/length under the
9292+mutex, then spawns `fifo_thread`. The thread mimics a hardware DMA interrupt
9393+loop:
9494+9595+```
9696+while not stopped:
9797+ 1. lock → grab (data, size) → clear pcm_data/pcm_size → unlock
9898+ 2. if data:
9999+ while size > 0 and not stopped:
100100+ n = write(fifo_fd, data, size)
101101+ handle EINTR/EAGAIN (retry)
102102+ advance data pointer, decrement size
103103+ 3. lock → pcm_play_dma_complete_callback(OK, &pcm_data, &pcm_size) → unlock
104104+ 4. if no more data: break
105105+ 5. pcm_play_dma_status_callback(STARTED) ← tells audio engine chunk consumed
106106+```
107107+108108+The inner write loop handles partial writes and `EINTR`/`EAGAIN` correctly,
109109+advancing the pointer on each successful `write()` call. Pacing comes
110110+naturally from the blocking FIFO write — the kernel suspends the thread until
111111+the reader drains data, keeping throughput locked to the consumer's read rate.
112112+113113+---
114114+115115+## FIFO pre-open strategy
116116+117117+`pcm_fifo_set_path(path)` is called once at startup from Rust settings code.
118118+It does two things:
119119+120120+### 1. Create the FIFO
121121+122122+```c
123123+mkfifo(path, 0666); // EEXIST is ignored
124124+```
125125+126126+This ensures the filesystem entry exists before snapserver starts, so
127127+snapserver's `open()` can succeed immediately.
128128+129129+### 2. Open with a permanent writer reference
130130+131131+```c
132132+fd = open(path, O_RDWR | O_NONBLOCK);
133133+// then clear O_NONBLOCK:
134134+flags = fcntl(fd, F_GETFL);
135135+fcntl(fd, F_SETFL, flags & ~O_NONBLOCK);
136136+```
137137+138138+**Why `O_RDWR`?** On Linux and macOS, opening a FIFO `O_WRONLY` blocks until
139139+a reader is present. `O_RDWR` succeeds immediately even with no reader, and
140140+critically it keeps the FIFO's open-writer-count at ≥1 for the entire lifetime
141141+of the process. This means:
142142+143143+- A reader that comes and goes (snapserver restart, client reconnect) never
144144+ causes the FIFO writer to receive `EPIPE` or the reader to see premature EOF.
145145+- Between tracks, when `fifo_thread` exits and `fifo_fd` is not closed,
146146+ snapserver's reader stays connected and continues to block-read cleanly.
147147+148148+**Why clear `O_NONBLOCK`?** After pre-opening, writes must block when the
149149+kernel pipe buffer is full, providing natural back-pressure / pacing. If
150150+`O_NONBLOCK` were left set, writes would return `EAGAIN` when the consumer is
151151+slow, corrupting the stream.
152152+153153+---
154154+155155+## stdout mode
156156+157157+When `fifo_path = "-"`, the sink writes to stdout. This enables piping:
158158+159159+```sh
160160+rockboxd | ffplay -f s16le -ar 44100 -ac 2 -
161161+```
162162+163163+Because Rockbox C code internally uses `printf()`/`puts()` on fd 1, stdout
164164+mode redirects fd 1 to stderr before any PCM is written:
165165+166166+```c
167167+static void redirect_stdout_to_stderr(void)
168168+{
169169+ stdout_pcm_fd = dup(STDOUT_FILENO); // save real stdout
170170+ dup2(STDERR_FILENO, STDOUT_FILENO); // fd 1 → stderr
171171+}
172172+```
173173+174174+All subsequent `printf()` output goes to stderr (visible in the terminal but
175175+not in the pipe), while PCM writes go to `stdout_pcm_fd` — the saved copy of
176176+the original stdout. The PCM stream is never polluted by log output.
177177+178178+This works only if `redirect_stdout_to_stderr()` is called before any C code
179179+writes to fd 1. `pcm_fifo_set_path("-")` calls it at startup, which is before
180180+any audio decoding begins.
181181+182182+---
183183+184184+## Track transitions and EOF prevention
185185+186186+`sink_dma_stop()` **does not close `fifo_fd`**:
187187+188188+```c
189189+static void sink_dma_stop(void)
190190+{
191191+ fifo_stop = true;
192192+ if (fifo_running) {
193193+ pthread_join(fifo_tid, NULL);
194194+ fifo_running = false;
195195+ }
196196+ // fifo_fd intentionally left open
197197+}
198198+```
199199+200200+On POSIX, a named FIFO's read end sees EOF only when all write-side file
201201+descriptors are closed. By keeping `fifo_fd` open across track boundaries,
202202+the consumer (snapserver) sees a continuous stream with no gaps. It never has
203203+to reconnect or re-open the pipe.
204204+205205+The only time `fifo_fd` is closed is if `pcm_fifo_set_path()` is called again
206206+with a new path — an operation that doesn't happen at runtime.
207207+208208+---
209209+210210+## FFI boundary (`crates/sys`)
211211+212212+`crates/sys/src/lib.rs` declares the C function:
213213+214214+```rust
215215+extern "C" {
216216+ pub fn pcm_fifo_set_path(path: *const c_char);
217217+}
218218+```
219219+220220+`crates/sys/src/sound/pcm.rs` wraps it safely:
221221+222222+```rust
223223+pub fn fifo_set_path(path: &str) {
224224+ let cpath = CString::new(path).expect("path must not contain null bytes");
225225+ unsafe { crate::pcm_fifo_set_path(cpath.as_ptr()) }
226226+ std::mem::forget(cpath); // C code only reads during init — leaking is fine
227227+}
228228+```
229229+230230+`std::mem::forget` is used because `pcm_fifo_set_path` stores a raw pointer to
231231+the string and reads it later (e.g. in `sink_dma_start`'s fallback path).
232232+Dropping the `CString` would free the memory while C still holds a dangling
233233+pointer. Since this is a one-time startup call, leaking is acceptable.
234234+235235+`pcm_switch_sink(PCM_SINK_FIFO)` switches the active sink. This is also an
236236+`extern "C"` call wrapped in `pcm::switch_sink(sink: i32) -> bool`.
237237+238238+---
239239+240240+## Settings and startup (`crates/settings`)
241241+242242+`crates/settings/src/lib.rs:load_settings()` reads
243243+`~/.config/rockbox.org/settings.toml` and handles the FIFO case:
244244+245245+```rust
246246+Some("fifo") => {
247247+ let path = settings.fifo_path.as_deref().unwrap_or("/tmp/rockbox.fifo");
248248+ pcm::fifo_set_path(path);
249249+ pcm::switch_sink(pcm::PCM_SINK_FIFO);
250250+ tracing::info!("audio output: fifo ({})", path);
251251+}
252252+```
253253+254254+Relevant `settings.toml` keys:
255255+256256+| Key | Type | Default | Description |
257257+|----------------|--------|-----------------------|---------------------------------------|
258258+| `audio_output` | string | `"builtin"` | Set to `"fifo"` to activate this sink |
259259+| `fifo_path` | string | `"/tmp/rockbox.fifo"` | FIFO path, or `"-"` for stdout |
260260+261261+---
262262+263263+## Snapcast integration
264264+265265+[Snapcast](https://github.com/badaix/snapcast) is a synchronised multi-room
266266+audio system. snapserver reads a PCM source and distributes it to one or more
267267+snapclient instances with sub-millisecond synchronisation.
268268+269269+The FIFO sink is designed to feed snapserver's `pipe://` source type. Once
270270+configured, the stream looks like:
271271+272272+```
273273+rockboxd ──write()──▶ /tmp/snapfifo ──read()──▶ snapserver
274274+ │
275275+ ┌──────────┴──────────┐
276276+ ▼ ▼
277277+ snapclient snapclient
278278+ (living room) (kitchen)
279279+```
280280+281281+### snapserver configuration
282282+283283+Add a stream source to `/etc/snapserver.conf` (or
284284+`/usr/local/etc/snapserver.conf` on macOS):
285285+286286+```ini
287287+[stream]
288288+source = pipe:///tmp/snapfifo?name=default&sampleformat=44100:16:2
289289+```
290290+291291+The `sampleformat=44100:16:2` parameter is required on snapserver v0.35+.
292292+The `-s` CLI flag is **ignored** on macOS; it must be set in the config file.
293293+294294+Start snapserver after rockboxd is running (see startup order below):
295295+296296+```sh
297297+snapserver
298298+```
299299+300300+Connect clients:
301301+302302+```sh
303303+snapclient --host localhost --player default
304304+```
305305+306306+---
307307+308308+## Startup order
309309+310310+**rockboxd must start before snapserver.**
311311+312312+If snapserver opens the FIFO first (before rockboxd calls `pcm_fifo_set_path`
313313+which does the `O_RDWR` open), it gets the sole writer reference. When
314314+snapserver later closes its end, the FIFO appears to have no writers and
315315+subsequent readers see immediate EOF.
316316+317317+Correct order:
318318+319319+```
320320+1. rockboxd starts → pcm_fifo_set_path() → FIFO created, O_RDWR fd held
321321+2. snapserver starts → opens FIFO O_RDONLY → blocks until data flows
322322+3. Playback begins → fifo_thread writes → snapserver reads → clients play
323323+```
324324+325325+---
326326+327327+## Snapserver configuration (macOS)
328328+329329+On macOS, snapserver's `-s` (stream source) command-line flag is silently
330330+ignored. The only way to configure the source is via the config file:
331331+332332+```ini
333333+# /usr/local/etc/snapserver.conf
334334+[stream]
335335+source = pipe:///tmp/snapfifo?name=default&sampleformat=44100:16:2
336336+```
337337+338338+Verify snapserver is reading it:
339339+340340+```sh
341341+snapserver --config /usr/local/etc/snapserver.conf
342342+```
343343+344344+---
345345+346346+## Other pipe consumers
347347+348348+Since the FIFO carries raw S16LE stereo 44100 Hz PCM, it works with any tool
349349+that accepts that format:
350350+351351+```sh
352352+# Play directly with ffplay
353353+rockboxd | ffplay -f s16le -ar 44100 -ac 2 -
354354+355355+# Encode on the fly with ffmpeg
356356+rockboxd | ffmpeg -f s16le -ar 44100 -ac 2 -i - output.mp3
357357+358358+# Play with sox
359359+rockboxd | play -t raw -r 44100 -e signed -b 16 -c 2 -
360360+361361+# Inspect levels with aplay (Linux)
362362+rockboxd | aplay -f S16_LE -r 44100 -c 2
363363+```
364364+365365+All of these require `fifo_path = "-"` in `settings.toml` so rockboxd writes
366366+to stdout.
367367+368368+---
369369+370370+## Gotchas and known limits
371371+372372+### 1. Startup order is critical
373373+374374+As described above, rockboxd must open the FIFO before snapserver. If
375375+snapserver opens it first and later closes its write end, snapclient may see
376376+EOF and stop buffering. Restart snapserver after rockboxd in that case.
377377+378378+### 2. Fixed 44100 Hz, S16LE stereo
379379+380380+The FIFO sink does not resample. The `set_freq` op is a no-op. If Rockbox
381381+decodes a 48 kHz or 96 kHz track, the firmware resamples it internally to
382382+the codec's sample rate, but the PCM output is always delivered to the sink
383383+at 44100 Hz. Configure snapserver and any other consumer to match.
384384+385385+### 3. No volume control through the sink
386386+387387+Volume is applied by the Rockbox DSP pipeline before PCM reaches the sink.
388388+The FIFO sink itself has no volume knob. Adjust volume through the Rockbox
389389+API or client applications.
390390+391391+### 4. Consumer back-pressure controls playback speed
392392+393393+Because `fifo_fd` is in blocking mode, a slow or stalled consumer will cause
394394+`write()` to block, which stalls `fifo_thread`, which eventually stalls the
395395+DMA callback loop, which pauses decoding. This is correct behavior for
396396+synchronized output, but it means a crashed or frozen snapserver will freeze
397397+playback. Restart snapserver to recover.
398398+399399+### 5. macOS `snapserver.conf` vs CLI flag
400400+401401+The `-s` flag to snapserver is silently ignored on macOS (at least v0.35.0).
402402+Always use the config file. See [Snapserver configuration (macOS)](#snapserver-configuration-macos).
403403+404404+### 6. Logging uses `tracing`, never `println!`
405405+406406+All Rust-side diagnostic output must go through `tracing`. `println!` and
407407+`eprintln!` bypass the log filter and — in stdout mode — corrupt the PCM
408408+stream. Use `RUST_LOG=debug rockboxd` to see debug output on stderr.