This document traces every hop an audio frame takes from the Rockbox C firmware through the Snapcast PCM sinks to a Snapcast server.
Two complementary sinks are available:
FIFO / pipe |
audio_output = "fifo"| Named FIFO or stdout |pipe://TCP (direct) |
audio_output = "snapcast_tcp"| TCP socket |tcp://|
The FIFO sink is the traditional approach: rockboxd writes to a named pipe that snapserver reads. The TCP sink connects directly to snapserver's TCP source port ā no FIFO, no filesystem dependency, auto-discoverable via mDNS.
Table of contents
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
FFI boundary (
crates/sys) - 7.
Settings and startup (
crates/settings) - 8.
- 9.
Overview
Both sinks write raw S16LE stereo PCM at 44100 Hz ā the same byte stream snapserver expects regardless of source type. There is no Rust crate involved, both are pure-C PCM sinks with a thin Rust FFI wrapper for configuration.
Choosing FIFO vs TCP
| | FIFO sink | TCP sink |
|---|---|---|
| Filesystem entry required | Yes (/tmp/snapfifo) | No |
| Snapserver source type | pipe:// | tcp:// |
| Startup order sensitive | Yes ā rockboxd first | Yes ā snapserver first |
| Reconnect on snapserver restart | No (FIFO stays open) | Yes (auto on next play) |
| Auto-discovered in UI | No (static virtual device) | Yes (mDNS _snapcast._tcp.local.) |
| stdout pipe support | Yes (fifo_path = "-") | No |
| Config | fifo_path | snapcast_tcp_host + snapcast_tcp_port |
Use FIFO when you want stdout piping or prefer the traditional pipe model.
Use TCP when you want UI-based auto-discovery, multiple snapservers, or don't want a filesystem dependency.
FIFO sink
FIFO layer map
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā Rockbox C firmware (pcm.c, audio thread) ā
ā pcm_play_data() ā sink.ops.play() ā
ā pcm_play_dma_complete_callback() per chunk ā
āāāāāāāāāāāāāāāāāāāāā¬āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā raw S16LE stereo PCM chunks
āāāāāāāāāāāāāāāāāāāāā¼āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā firmware/target/hosted/pcm-fifo.c ā
ā pcm_fifo_set_path() ā pre-creates FIFO, opens fd ā
ā sink_dma_start() ā spawns fifo_thread ā
ā fifo_thread() ā blocking write() loop ā
ā sink_dma_stop() ā signals thread, keeps fd ā
āāāāāāāāāāāāāāāāāāāāā¬āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā blocking write() to FIFO or stdout
āāāāāāāāāāāāāāāāāāāāā¼āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā Named FIFO (/tmp/snapfifo) or stdout ā
āāāāāāāāāāāāāāāāāāāāā¬āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā read()
āāāāāāāāāāāāāāāāāāāāā¼āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā snapserver (pipe:// source) ā
ā ā or ā ā
ā ffplay / aplay / custom consumer ā
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
PCM sink vtable (pcm-fifo.c)
firmware/target/hosted/pcm-fifo.c implements struct pcm_sink:
| Op | Implementation |
|-------------------|-------------------------------------------------------------|
| init | pthread_mutex_init (recursive) |
| postinit | no-op |
| set_freq | no-op (output is always 44100 Hz; snapserver must match) |
| lock / unlock | pthread_mutex_lock/unlock |
| play | sink_dma_start ā opens fd if needed, spawns fifo_thread |
| stop | sink_dma_stop ā signals thread, joins; keeps fd open |
fifo_pcm_sink is registered at index PCM_SINK_FIFO = 1 in firmware/pcm.c.
The DMA thread
sink_dma_start(addr, size) stores the initial PCM pointer/length under the mutex, then spawns fifo_thread. The thread mimics a hardware DMA interrupt
loop:
while not stopped:
1. lock ā grab (data, size) ā clear pcm_data/pcm_size ā unlock
2. while size > 0 and not stopped:
n = write(fifo_fd, data, size)
handle EINTR/EAGAIN (retry)
advance data pointer, decrement size
3. lock ā pcm_play_dma_complete_callback(OK, &pcm_data, &pcm_size) ā unlock
4. if no more data: break
5. pcm_play_dma_status_callback(STARTED)
Pacing comes naturally from the blocking FIFO write ā the kernel suspends the
thread until the reader drains data, locking throughput to the consumer's rate.
FIFO pre-open strategy
pcm_fifo_set_path(path) is called once at startup:
1. Create the FIFO
mkfifo(path, 0666); // EEXIST is ignored
2. Open with a permanent writer reference
fd = open(path, O_RDWR | O_NONBLOCK);
// then clear O_NONBLOCK:
fcntl(fd, F_SETFL, flags & ~O_NONBLOCK);
Why O_RDWR? Opening O_WRONLY blocks until a reader is present. O_RDWR succeeds immediately and keeps the open-writer-count at ā„1 for the process lifetime ā snapserver never sees premature EOF between tracks.
Why clear O_NONBLOCK? Writes must block when the kernel buffer is full to provide natural back-pressure. Leaving O_NONBLOCK set would produce EAGAIN and corrupt the stream.
stdout mode
When fifo_path = "-", the sink writes to stdout:
rockboxd | ffplay -f s16le -ar 44100 -ac 2 -
pcm_fifo_set_path("-") redirects fd 1 to stderr before any PCM is written so internal printf() output never pollutes the PCM stream.
Track transitions and EOF prevention
sink_dma_stop() does not close fifo_fd. On POSIX, a named FIFO's read end sees EOF only when all write-side fds are closed. By keeping fifo_fd open across track boundaries, snapserver sees a continuous stream with no gaps.
Startup order (FIFO)
rockboxd must start before snapserver.
1. rockboxd starts ā pcm_fifo_set_path() ā FIFO created, O_RDWR fd held
2. snapserver starts ā opens FIFO O_RDONLY ā blocks until data flows
3. Playback begins ā fifo_thread writes ā snapserver distributes to clients
Snapserver configuration (FIFO)
# /etc/snapserver.conf (or /usr/local/etc/snapserver.conf on macOS)
[stream]
source = pipe:///tmp/snapfifo?name=default&sampleformat=44100:16:2
On macOS, snapserver ā„ v0.35.0 ignores the -s CLI flag. Use the configfile.
TCP sink
TCP layer map
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā Rockbox C firmware (pcm.c, audio thread) ā
ā pcm_play_data() ā sink.ops.play() ā
ā pcm_play_dma_complete_callback() per chunk ā
āāāāāāāāāāāāāāāāāāāāā¬āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā raw S16LE stereo PCM chunks
āāāāāāāāāāāāāāāāāāāāā¼āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā firmware/target/hosted/pcm-tcp.c ā
ā pcm_tcp_set_host() / pcm_tcp_set_port() ā
ā sink_dma_start() ā connects if needed, spawns thread ā
ā tcp_thread() ā blocking write() loop ā
ā sink_dma_stop() ā signals thread, keeps socket ā
āāāāāāāāāāāāāāāāāāāāā¬āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā blocking write() over TCP
āāāāāāāāāāāāāāāāāāāāā¼āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā TCP socket (snapserver host:port) ā
āāāāāāāāāāāāāāāāāāāāā¬āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā recv()
āāāāāāāāāāāāāāāāāāāāā¼āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā snapserver (tcp:// source, server mode) ā
ā ā ā
ā āāāāā“āāāāāāā¬āāāāāāāāāāā ā
ā ā¼ ā¼ ā¼ ā
ā snapclient snapclient snapclient ā
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
PCM sink vtable (pcm-tcp.c)
firmware/target/hosted/pcm-tcp.c implements struct pcm_sink:
| Op | Implementation |
|-------------------|-------------------------------------------------------------|
| init | pthread_mutex_init (recursive) |
| postinit | no-op |
| set_freq | no-op (output is always 44100 Hz; snapserver must match) |
| lock / unlock | pthread_mutex_lock/unlock |
| play | sink_dma_start ā connects if needed, spawns tcp_thread |
| stop | sink_dma_stop ā signals thread, joins; keeps socket open |
tcp_pcm_sink is registered at index PCM_SINK_SNAPCAST_TCP = 6 in
firmware/pcm.c.
Connection lifecycle
sink_dma_start() calls tcp_connect_once() if tcp_fd < 0:
static int tcp_connect_once(void)
{
// getaddrinfo(tcp_host, port) ā socket() ā connect()
// returns fd on success, -1 on failure (logs error, drops audio)
}
The socket is kept open across stop() ā play() transitions, just as the FIFO fd is. snapserver's reader sees a continuous stream between tracks.
Reconnect on error
If write() returns a hard error (EPIPE, ECONNRESET, etc.), tcp_thread closes the socket (tcp_fd = -1) and sets tcp_stop = true. The next call tosink_dma_start() finds tcp_fd < 0 and attempts a fresh connect(). This handles snapserver restarts gracefully ā the connection is re-established automatically on the next track or resume.
Startup order (TCP)
snapserver must be running and listening before playback starts.
1. snapserver starts ā listens on tcp://0.0.0.0:4953
2. rockboxd starts ā pcm_tcp_set_host/port() stores config
3. Playback begins ā sink_dma_start() ā tcp_connect_once() ā connects
4. tcp_thread writes ā snapserver receives ā distributes to clients
Unlike the FIFO sink there is no permanent pre-connection at startup. The socket is opened on the first play() call.
Snapserver configuration (TCP)
# /etc/snapserver.conf (or /usr/local/etc/snapserver.conf on macOS)
[stream]
source = tcp://0.0.0.0:4953?name=default&sampleformat=44100:16:2
settings.toml (manual config, not needed when selecting from the UI):
audio_output = "snapcast_tcp"
snapcast_tcp_host = "192.168.1.x" # IP of the machine running snapserver
snapcast_tcp_port = 4953 # default snapserver TCP source port
Auto-discovery via mDNS
snapserver advertises itself via mDNS as _snapcast._tcp.local.. rockboxd
scans for this service at startup via scan_snapcast_servers() in
crates/server/src/scan.rs, which browses _snapcast._tcp.local. using the
mdns-sd crate and adds discovered servers to the shared devices list.
Discovered servers appear immediately in:
Web UI ā the device picker in the control bar (lime-green radio icon)
Desktop app (GPUI) ā the device picker popup
Clicking a discovered server calls PUT /devices/:id/connect, which:
- 1.
Calls
pcm_tcp_set_host(device.ip)andpcm_tcp_set_port(device.port). - 2.
Calls
pcm_switch_sink(PCM_SINK_SNAPCAST_TCP). - 3.
Persists
audio_output = "snapcast_tcp",snapcast_tcp_host, and
No manual settings.toml editing is needed when using the UI.
FFI boundary (crates/sys)
FIFO
// crates/sys/src/lib.rs
extern "C" { fn pcm_fifo_set_path(path: *const c_char); }
// crates/sys/src/sound/pcm.rs
pub fn fifo_set_path(path: &str) {
let cpath = CString::new(path).expect("path must not contain null bytes");
unsafe { crate::pcm_fifo_set_path(cpath.as_ptr()) }
std::mem::forget(cpath); // C code stores and re-reads pointer at runtime
}
TCP
// crates/sys/src/lib.rs
extern "C" {
fn pcm_tcp_set_host(host: *const c_char);
fn pcm_tcp_set_port(port: c_ushort);
}
// crates/sys/src/sound/pcm.rs
pub fn tcp_set_host(host: &str) {
let chost = CString::new(host).expect("host must not contain null bytes");
unsafe { crate::pcm_tcp_set_host(chost.as_ptr()) }
std::mem::forget(chost);
}
pub fn tcp_set_port(port: u16) {
unsafe { crate::pcm_tcp_set_port(port) }
}
std::mem::forget is used in both cases because the C code stores the raw pointer and reads it later (in sink_dma_start's connect / fallback path).
Dropping the CString would free the memory while C holds a dangling pointer.
Since these are startup-time config calls, leaking is acceptable.
Settings and startup (crates/settings)
crates/settings/src/lib.rs:load_settings() handles both sinks:
Some("fifo") => {
let path = settings.fifo_path.as_deref().unwrap_or("/tmp/rockbox.fifo");
pcm::fifo_set_path(path);
pcm::switch_sink(pcm::PCM_SINK_FIFO);
}
Some("snapcast_tcp") => {
if let Some(ref host) = settings.snapcast_tcp_host {
let port = settings.snapcast_tcp_port.unwrap_or(4953);
pcm::tcp_set_host(host);
pcm::tcp_set_port(port);
pcm::switch_sink(pcm::PCM_SINK_SNAPCAST_TCP);
}
}
All Snapcast settings keys
| Key | Type | Default | Sink | Description |
|----------------------|--------|-----------------------|-------|------------------------------------------|
| audio_output | string | "builtin" | both | "fifo" or "snapcast_tcp" |
| fifo_path | string | "/tmp/rockbox.fifo" | FIFO | FIFO path, or "-" for stdout |
| snapcast_tcp_host | string | ā | TCP | IP / hostname of the snapserver machine |
| snapcast_tcp_port | u16 | 4953 | TCP | snapserver TCP source port |
Other pipe consumers
Since both sinks carry raw S16LE stereo 44100 Hz PCM, the FIFO sink works with any tool that accepts that format:
# Play directly with ffplay (stdout mode)
rockboxd | ffplay -f s16le -ar 44100 -ac 2 -
# Encode on the fly
rockboxd | ffmpeg -f s16le -ar 44100 -ac 2 -i - output.mp3
# Play with sox
rockboxd | play -t raw -r 44100 -e signed -b 16 -c 2 -
# Inspect levels with aplay (Linux)
rockboxd | aplay -f S16_LE -r 44100 -c 2
All of these require fifo_path = "-" and are only available with the FIFO sink. The TCP sink does not support stdout mode.
Gotchas and known limits
1. Startup order is critical for both sinks
FIFO: rockboxd must open the FIFO before snapserver. Reverse order causes
TCP: snapserver must be listening before playback starts. If snapserver
2. Fixed 44100 Hz, S16LE stereo
Neither sink resamples. set_freq is a no-op. The firmware resamples tracks internally before they reach the sink, but the output is always 44100 Hz.
Configure sampleformat=44100:16:2 on the snapserver side.
3. No volume control through the sink
Volume is applied by the Rockbox DSP pipeline before PCM reaches the sink.
Adjust volume through the Rockbox API or client applications.
4. Consumer back-pressure controls playback speed
Both sinks use blocking write(). A slow or stalled consumer stalls
write(), which stalls the DMA callback loop, which pauses decoding. This is correct for synchronized output but means a crashed consumer freezes
playback. Restart snapserver to recover.
5. macOS snapserver.conf vs CLI flag
The -s flag to snapserver is silently ignored on macOS (ā„ v0.35.0).
Always use the config file for both pipe:// and tcp:// sources.
6. TCP reconnect drops in-flight buffer
When the write loop detects EPIPE it closes the socket immediately. The current audio buffer is discarded. Reconnection happens on the next
sink_dma_start() call, so there will be a brief audio gap when snapserver restarts.
7. Logging uses tracing, never println!
All Rust-side diagnostic output must go through tracing. println! and eprintln! bypass the log filter and ā in stdout/FIFO mode ā can corrupt the PCM stream. Use RUST_LOG=debug rockboxd to see debug output on stderr.
The full implementation is available at github.com/tsirysndr/rockbox-zig.