Skip to content

Latest commit

 

History

History
476 lines (359 loc) · 19.9 KB

File metadata and controls

476 lines (359 loc) · 19.9 KB

Snapcast — FIFO & TCP PCM Sinks

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:

Sink Setting value Transport Snapserver source type
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. Overview
  2. Choosing FIFO vs TCP
  3. FIFO sink
  4. TCP sink
  5. Auto-discovery via mDNS
  6. FFI boundary (crates/sys)
  7. Settings and startup (crates/settings)
  8. Other pipe consumers
  9. Gotchas and known limits

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 config file.


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 to sink_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) and pcm_tcp_set_port(device.port).
  2. Calls pcm_switch_sink(PCM_SINK_SNAPCAST_TCP).
  3. Persists audio_output = "snapcast_tcp", snapcast_tcp_host, and snapcast_tcp_port to settings.toml so the selection survives restart.

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 snapserver to hold the only writer reference; when it closes, readers see EOF. Restart snapserver if this happens.
  • TCP: snapserver must be listening before playback starts. If snapserver is not yet running, sink_dma_start logs a warning and drops audio for that buffer. It reconnects automatically on the next play call.

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.