···3131### Audio output
3232- [x] Built-in SDL audio
3333- [x] AirPlay (RAOP) — single or multi-room fan-out to Apple TV, HomePod, Airport Express, shairport-sync
3434-- [x] Snapcast (FIFO/pipe) — synchronised multi-room via snapserver
3434+- [x] Snapcast — synchronised multi-room via snapserver (FIFO/pipe **and** direct TCP with mDNS auto-discovery)
3535- [x] Squeezelite (Slim Protocol + HTTP broadcast) — synchronised multi-room
3636- [x] Chromecast
3737- [x] Gapless playback and crossfading
···204204205205Uses SDL2 audio — plays through the OS default device. No extra setup needed.
206206207207-### Snapcast (FIFO / pipe)
207207+### Snapcast
208208+209209+Rockbox supports two ways to feed [Snapcast](https://github.com/badaix/snapcast)
210210+for synchronised multi-room playback. Both write raw **S16LE stereo 44100 Hz**
211211+PCM to snapserver.
212212+213213+#### TCP (recommended — auto-discovery)
214214+215215+```toml
216216+music_dir = "/path/to/Music"
217217+audio_output = "snapcast_tcp"
218218+snapcast_tcp_host = "192.168.1.x" # IP of the machine running snapserver
219219+snapcast_tcp_port = 4953 # default snapserver TCP source port
220220+```
221221+222222+Connects directly to snapserver's TCP source port. No named FIFO or filesystem
223223+dependency needed.
224224+225225+```ini
226226+# /etc/snapserver.conf (or /usr/local/etc/snapserver.conf on macOS)
227227+[stream]
228228+source = tcp://0.0.0.0:4953?name=default&sampleformat=44100:16:2
229229+```
230230+231231+> **Startup order**: start `snapserver` first so it is already listening when
232232+> rockboxd begins playback. If the connection drops (e.g. snapserver restarts),
233233+> it is re-established automatically on the next play call.
234234+235235+> **Auto-discovery**: rockboxd scans for `_snapcast._tcp.local.` via mDNS at
236236+> startup. Discovered servers appear in the web UI and desktop app device
237237+> picker — just click to connect, no config file editing needed.
238238+239239+#### FIFO / pipe
208240209241```toml
210242music_dir = "/path/to/Music"
···212244fifo_path = "/tmp/snapfifo" # named FIFO for snapserver; use "-" for stdout
213245```
214246215215-Writes raw **S16LE stereo 44100 Hz** PCM to a named FIFO. Feed it into
216216-[Snapcast](https://github.com/badaix/snapcast) for synchronised multi-room
217217-playback:
247247+Writes to a named FIFO. Use this when you need stdout piping or prefer the
248248+traditional pipe model.
218249219250```ini
220251# /etc/snapserver.conf (or /usr/local/etc/snapserver.conf on macOS)
···231262```sh
232263rockboxd | ffplay -f s16le -ar 44100 -ac 2 -
233264```
265265+266266+See [SNAPCAST.md](./SNAPCAST.md) for a detailed comparison of both modes,
267267+connection lifecycle, reconnect behaviour, and macOS quirks.
234268235269### AirPlay (RAOP) — single or multi-room
236270
+285-217
SNAPCAST.md
···11-# Snapcast / FIFO PCM Sink
11+# Snapcast — FIFO & TCP PCM Sinks
2233This 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).
44+through the Snapcast PCM sinks to a Snapcast server.
55+66+Two complementary sinks are available:
77+88+| Sink | Setting value | Transport | Snapserver source type |
99+|------|--------------|-----------|------------------------|
1010+| FIFO / pipe | `audio_output = "fifo"` | Named FIFO or stdout | `pipe://` |
1111+| TCP (direct) | `audio_output = "snapcast_tcp"` | TCP socket | `tcp://` |
1212+1313+The **FIFO sink** is the traditional approach: rockboxd writes to a named pipe
1414+that snapserver reads. The **TCP sink** connects directly to snapserver's TCP
1515+source port — no FIFO, no filesystem dependency, auto-discoverable via mDNS.
516617---
718819## Table of contents
92010211. [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)
2222+2. [Choosing FIFO vs TCP](#choosing-fifo-vs-tcp)
2323+3. [FIFO sink](#fifo-sink)
2424+ - [Layer map](#fifo-layer-map)
2525+ - [PCM sink vtable](#pcm-sink-vtable-pcm-fifoc)
2626+ - [The DMA thread](#the-dma-thread)
2727+ - [FIFO pre-open strategy](#fifo-pre-open-strategy)
2828+ - [stdout mode](#stdout-mode)
2929+ - [Track transitions and EOF prevention](#track-transitions-and-eof-prevention)
3030+ - [Startup order](#startup-order-fifo)
3131+ - [Snapserver configuration](#snapserver-configuration-fifo)
3232+4. [TCP sink](#tcp-sink)
3333+ - [Layer map](#tcp-layer-map)
3434+ - [PCM sink vtable](#pcm-sink-vtable-pcm-tcpc)
3535+ - [Connection lifecycle](#connection-lifecycle)
3636+ - [Reconnect on error](#reconnect-on-error)
3737+ - [Startup order](#startup-order-tcp)
3838+ - [Snapserver configuration](#snapserver-configuration-tcp)
3939+5. [Auto-discovery via mDNS](#auto-discovery-via-mdns)
4040+6. [FFI boundary (`crates/sys`)](#ffi-boundary-cratessys)
4141+7. [Settings and startup (`crates/settings`)](#settings-and-startup-cratessettings)
4242+8. [Other pipe consumers](#other-pipe-consumers)
4343+9. [Gotchas and known limits](#gotchas-and-known-limits)
24442545---
26462747## Overview
28482929-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.
4949+Both sinks write raw **S16LE stereo PCM at 44100 Hz** — the same byte stream
5050+snapserver expects regardless of source type. There is no Rust crate involved;
5151+both are pure-C PCM sinks with a thin Rust FFI wrapper for configuration.
5252+5353+---
34543535-There is no Rust crate involved. This is a pure-C PCM sink with a thin Rust
3636-FFI wrapper for configuration.
5555+## Choosing FIFO vs TCP
5656+5757+| | FIFO sink | TCP sink |
5858+|---|---|---|
5959+| Filesystem entry required | Yes (`/tmp/snapfifo`) | No |
6060+| Snapserver source type | `pipe://` | `tcp://` |
6161+| Startup order sensitive | Yes — rockboxd first | Yes — snapserver first |
6262+| Reconnect on snapserver restart | No (FIFO stays open) | Yes (auto on next play) |
6363+| Auto-discovered in UI | No (static virtual device) | Yes (mDNS `_snapcast._tcp.local.`) |
6464+| stdout pipe support | Yes (`fifo_path = "-"`) | No |
6565+| Config | `fifo_path` | `snapcast_tcp_host` + `snapcast_tcp_port` |
6666+6767+**Use FIFO** when you want stdout piping or prefer the traditional pipe model.
6868+6969+**Use TCP** when you want UI-based auto-discovery, multiple snapservers, or
7070+don't want a filesystem dependency.
37713872---
39734040-## Layer map
7474+## FIFO sink
7575+7676+### FIFO layer map
41774278```
4379┌────────────────────────────────────────────────────────┐
···65101└────────────────────────────────────────────────────────┘
66102```
671036868----
104104+### PCM sink vtable (`pcm-fifo.c`)
691057070-## PCM sink vtable (`pcm-fifo.c`)
7171-7272-`firmware/target/hosted/pcm-fifo.c` implements `struct pcm_sink` with the
7373-following vtable:
106106+`firmware/target/hosted/pcm-fifo.c` implements `struct pcm_sink`:
7410775108| Op | Implementation |
76109|-------------------|-------------------------------------------------------------|
···81114| `play` | `sink_dma_start` — opens fd if needed, spawns `fifo_thread` |
82115| `stop` | `sink_dma_stop` — signals thread, joins; keeps fd open |
831168484-`fifo_pcm_sink` is registered at index `PCM_SINK_FIFO = 1` in the `sinks[]`
8585-array in `firmware/pcm.c`.
117117+`fifo_pcm_sink` is registered at index `PCM_SINK_FIFO = 1` in `firmware/pcm.c`.
861188787----
8888-8989-## The DMA thread
119119+### The DMA thread
9012091121`sink_dma_start(addr, size)` stores the initial PCM pointer/length under the
92122mutex, then spawns `fifo_thread`. The thread mimics a hardware DMA interrupt
···95125```
96126while not stopped:
97127 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
128128+ 2. while size > 0 and not stopped:
129129+ n = write(fifo_fd, data, size)
130130+ handle EINTR/EAGAIN (retry)
131131+ advance data pointer, decrement size
103132 3. lock → pcm_play_dma_complete_callback(OK, &pcm_data, &pcm_size) → unlock
104133 4. if no more data: break
105105- 5. pcm_play_dma_status_callback(STARTED) ← tells audio engine chunk consumed
134134+ 5. pcm_play_dma_status_callback(STARTED)
106135```
107136108108-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.
137137+Pacing comes naturally from the blocking FIFO write — the kernel suspends the
138138+thread until the reader drains data, locking throughput to the consumer's rate.
112139113113----
140140+### FIFO pre-open strategy
114141115115-## FIFO pre-open strategy
142142+`pcm_fifo_set_path(path)` is called once at startup:
116143117117-`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
144144+#### 1. Create the FIFO
121145122146```c
123147mkfifo(path, 0666); // EEXIST is ignored
124148```
125149126126-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
150150+#### 2. Open with a permanent writer reference
130151131152```c
132153fd = open(path, O_RDWR | O_NONBLOCK);
133154// then clear O_NONBLOCK:
134134-flags = fcntl(fd, F_GETFL);
135155fcntl(fd, F_SETFL, flags & ~O_NONBLOCK);
136156```
137157138138-**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.
158158+**Why `O_RDWR`?** Opening `O_WRONLY` blocks until a reader is present. `O_RDWR`
159159+succeeds immediately and keeps the open-writer-count at ≥1 for the process
160160+lifetime — snapserver never sees premature EOF between tracks.
152161153153----
162162+**Why clear `O_NONBLOCK`?** Writes must block when the kernel buffer is full to
163163+provide natural back-pressure. Leaving `O_NONBLOCK` set would produce `EAGAIN`
164164+and corrupt the stream.
154165155155-## stdout mode
166166+### stdout mode
156167157157-When `fifo_path = "-"`, the sink writes to stdout. This enables piping:
168168+When `fifo_path = "-"`, the sink writes to stdout:
158169159170```sh
160171rockboxd | ffplay -f s16le -ar 44100 -ac 2 -
161172```
162173163163-Because Rockbox C code internally uses `printf()`/`puts()` on fd 1, stdout
164164-mode redirects fd 1 to stderr before any PCM is written:
174174+`pcm_fifo_set_path("-")` redirects fd 1 to stderr before any PCM is written so
175175+internal `printf()` output never pollutes the PCM stream.
165176166166-```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-```
177177+### Track transitions and EOF prevention
173178174174-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.
179179+`sink_dma_stop()` does **not** close `fifo_fd`. On POSIX, a named FIFO's read
180180+end sees EOF only when all write-side fds are closed. By keeping `fifo_fd` open
181181+across track boundaries, snapserver sees a continuous stream with no gaps.
177182178178-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.
183183+### Startup order (FIFO)
181184182182----
185185+**rockboxd must start before snapserver.**
183186184184-## Track transitions and EOF prevention
187187+```
188188+1. rockboxd starts → pcm_fifo_set_path() → FIFO created, O_RDWR fd held
189189+2. snapserver starts → opens FIFO O_RDONLY → blocks until data flows
190190+3. Playback begins → fifo_thread writes → snapserver distributes to clients
191191+```
185192186186-`sink_dma_stop()` **does not close `fifo_fd`**:
193193+### Snapserver configuration (FIFO)
187194188188-```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-}
195195+```ini
196196+# /etc/snapserver.conf (or /usr/local/etc/snapserver.conf on macOS)
197197+[stream]
198198+source = pipe:///tmp/snapfifo?name=default&sampleformat=44100:16:2
198199```
199200200200-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.
201201+> On macOS, snapserver ≥ v0.35.0 ignores the `-s` CLI flag. Use the config
202202+> file.
207203208204---
209205210210-## FFI boundary (`crates/sys`)
206206+## TCP sink
211207212212-`crates/sys/src/lib.rs` declares the C function:
208208+### TCP layer map
213209214214-```rust
215215-extern "C" {
216216- pub fn pcm_fifo_set_path(path: *const c_char);
217217-}
218210```
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-}
211211+┌────────────────────────────────────────────────────────┐
212212+│ Rockbox C firmware (pcm.c, audio thread) │
213213+│ pcm_play_data() → sink.ops.play() │
214214+│ pcm_play_dma_complete_callback() per chunk │
215215+└───────────────────┬────────────────────────────────────┘
216216+ │ raw S16LE stereo PCM chunks
217217+┌───────────────────▼────────────────────────────────────┐
218218+│ firmware/target/hosted/pcm-tcp.c │
219219+│ pcm_tcp_set_host() / pcm_tcp_set_port() │
220220+│ sink_dma_start() — connects if needed, spawns thread│
221221+│ tcp_thread() — blocking write() loop │
222222+│ sink_dma_stop() — signals thread, keeps socket │
223223+└───────────────────┬────────────────────────────────────┘
224224+ │ blocking write() over TCP
225225+┌───────────────────▼────────────────────────────────────┐
226226+│ TCP socket (snapserver host:port) │
227227+└───────────────────┬────────────────────────────────────┘
228228+ │ recv()
229229+┌───────────────────▼────────────────────────────────────┐
230230+│ snapserver (tcp:// source, server mode) │
231231+│ │ │
232232+│ ┌───┴──────┬──────────┐ │
233233+│ ▼ ▼ ▼ │
234234+│ snapclient snapclient snapclient │
235235+└────────────────────────────────────────────────────────┘
228236```
229237230230-`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.
238238+### PCM sink vtable (`pcm-tcp.c`)
234239235235-`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`.
240240+`firmware/target/hosted/pcm-tcp.c` implements `struct pcm_sink`:
237241238238----
242242+| Op | Implementation |
243243+|-------------------|-------------------------------------------------------------|
244244+| `init` | `pthread_mutex_init` (recursive) |
245245+| `postinit` | no-op |
246246+| `set_freq` | no-op (output is always 44100 Hz; snapserver must match) |
247247+| `lock` / `unlock` | `pthread_mutex_lock/unlock` |
248248+| `play` | `sink_dma_start` — connects if needed, spawns `tcp_thread` |
249249+| `stop` | `sink_dma_stop` — signals thread, joins; keeps socket open |
239250240240-## Settings and startup (`crates/settings`)
251251+`tcp_pcm_sink` is registered at index `PCM_SINK_SNAPCAST_TCP = 6` in
252252+`firmware/pcm.c`.
241253242242-`crates/settings/src/lib.rs:load_settings()` reads
243243-`~/.config/rockbox.org/settings.toml` and handles the FIFO case:
254254+### Connection lifecycle
244255245245-```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);
256256+`sink_dma_start()` calls `tcp_connect_once()` if `tcp_fd < 0`:
257257+258258+```c
259259+static int tcp_connect_once(void)
260260+{
261261+ // getaddrinfo(tcp_host, port) → socket() → connect()
262262+ // returns fd on success, -1 on failure (logs error, drops audio)
251263}
252264```
253265254254-Relevant `settings.toml` keys:
266266+The socket is kept open across `stop()` → `play()` transitions, just as the
267267+FIFO fd is. snapserver's reader sees a continuous stream between tracks.
255268256256-| 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 |
269269+### Reconnect on error
260270261261----
271271+If `write()` returns a hard error (`EPIPE`, `ECONNRESET`, etc.), `tcp_thread`
272272+closes the socket (`tcp_fd = -1`) and sets `tcp_stop = true`. The next call to
273273+`sink_dma_start()` finds `tcp_fd < 0` and attempts a fresh `connect()`. This
274274+handles snapserver restarts gracefully — the connection is re-established
275275+automatically on the next track or resume.
262276263263-## Snapcast integration
277277+### Startup order (TCP)
264278265265-[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:
279279+**snapserver must be running and listening before playback starts.**
271280272281```
273273-rockboxd ──write()──▶ /tmp/snapfifo ──read()──▶ snapserver
274274- │
275275- ┌──────────┴──────────┐
276276- ▼ ▼
277277- snapclient snapclient
278278- (living room) (kitchen)
282282+1. snapserver starts → listens on tcp://0.0.0.0:4953
283283+2. rockboxd starts → pcm_tcp_set_host/port() stores config
284284+3. Playback begins → sink_dma_start() → tcp_connect_once() → connects
285285+4. tcp_thread writes → snapserver receives → distributes to clients
279286```
280287281281-### snapserver configuration
288288+Unlike the FIFO sink there is no permanent pre-connection at startup. The
289289+socket is opened on the first `play()` call.
282290283283-Add a stream source to `/etc/snapserver.conf` (or
284284-`/usr/local/etc/snapserver.conf` on macOS):
291291+### Snapserver configuration (TCP)
285292286293```ini
294294+# /etc/snapserver.conf (or /usr/local/etc/snapserver.conf on macOS)
287295[stream]
288288-source = pipe:///tmp/snapfifo?name=default&sampleformat=44100:16:2
296296+source = tcp://0.0.0.0:4953?name=default&sampleformat=44100:16:2
289297```
290298291291-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.
299299+`settings.toml` (manual config, not needed when selecting from the UI):
300300+301301+```toml
302302+audio_output = "snapcast_tcp"
303303+snapcast_tcp_host = "192.168.1.x" # IP of the machine running snapserver
304304+snapcast_tcp_port = 4953 # default snapserver TCP source port
305305+```
306306+307307+---
308308+309309+## Auto-discovery via mDNS
310310+311311+snapserver advertises itself via mDNS as `_snapcast._tcp.local.`. rockboxd
312312+scans for this service at startup via `scan_snapcast_servers()` in
313313+`crates/server/src/scan.rs`, which browses `_snapcast._tcp.local.` using the
314314+`mdns-sd` crate and adds discovered servers to the shared devices list.
315315+316316+Discovered servers appear immediately in:
293317294294-Start snapserver after rockboxd is running (see startup order below):
318318+- **Web UI** — the device picker in the control bar (lime-green radio icon)
319319+- **Desktop app (GPUI)** — the device picker popup
295320296296-```sh
297297-snapserver
298298-```
321321+Clicking a discovered server calls `PUT /devices/:id/connect`, which:
299322300300-Connect clients:
323323+1. Calls `pcm_tcp_set_host(device.ip)` and `pcm_tcp_set_port(device.port)`.
324324+2. Calls `pcm_switch_sink(PCM_SINK_SNAPCAST_TCP)`.
325325+3. Persists `audio_output = "snapcast_tcp"`, `snapcast_tcp_host`, and
326326+ `snapcast_tcp_port` to `settings.toml` so the selection survives restart.
301327302302-```sh
303303-snapclient --host localhost --player default
304304-```
328328+No manual `settings.toml` editing is needed when using the UI.
305329306330---
307331308308-## Startup order
332332+## FFI boundary (`crates/sys`)
333333+334334+### FIFO
335335+336336+```rust
337337+// crates/sys/src/lib.rs
338338+extern "C" { fn pcm_fifo_set_path(path: *const c_char); }
339339+340340+// crates/sys/src/sound/pcm.rs
341341+pub fn fifo_set_path(path: &str) {
342342+ let cpath = CString::new(path).expect("path must not contain null bytes");
343343+ unsafe { crate::pcm_fifo_set_path(cpath.as_ptr()) }
344344+ std::mem::forget(cpath); // C code stores and re-reads pointer at runtime
345345+}
346346+```
309347310310-**rockboxd must start before snapserver.**
348348+### TCP
311349312312-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.
350350+```rust
351351+// crates/sys/src/lib.rs
352352+extern "C" {
353353+ fn pcm_tcp_set_host(host: *const c_char);
354354+ fn pcm_tcp_set_port(port: c_ushort);
355355+}
316356317317-Correct order:
357357+// crates/sys/src/sound/pcm.rs
358358+pub fn tcp_set_host(host: &str) {
359359+ let chost = CString::new(host).expect("host must not contain null bytes");
360360+ unsafe { crate::pcm_tcp_set_host(chost.as_ptr()) }
361361+ std::mem::forget(chost);
362362+}
318363364364+pub fn tcp_set_port(port: u16) {
365365+ unsafe { crate::pcm_tcp_set_port(port) }
366366+}
319367```
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-```
368368+369369+`std::mem::forget` is used in both cases because the C code stores the raw
370370+pointer and reads it later (in `sink_dma_start`'s connect / fallback path).
371371+Dropping the `CString` would free the memory while C holds a dangling pointer.
372372+Since these are startup-time config calls, leaking is acceptable.
324373325374---
326375327327-## Snapserver configuration (macOS)
376376+## Settings and startup (`crates/settings`)
328377329329-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:
378378+`crates/settings/src/lib.rs:load_settings()` handles both sinks:
331379332332-```ini
333333-# /usr/local/etc/snapserver.conf
334334-[stream]
335335-source = pipe:///tmp/snapfifo?name=default&sampleformat=44100:16:2
380380+```rust
381381+Some("fifo") => {
382382+ let path = settings.fifo_path.as_deref().unwrap_or("/tmp/rockbox.fifo");
383383+ pcm::fifo_set_path(path);
384384+ pcm::switch_sink(pcm::PCM_SINK_FIFO);
385385+}
386386+Some("snapcast_tcp") => {
387387+ if let Some(ref host) = settings.snapcast_tcp_host {
388388+ let port = settings.snapcast_tcp_port.unwrap_or(4953);
389389+ pcm::tcp_set_host(host);
390390+ pcm::tcp_set_port(port);
391391+ pcm::switch_sink(pcm::PCM_SINK_SNAPCAST_TCP);
392392+ }
393393+}
336394```
337395338338-Verify snapserver is reading it:
396396+### All Snapcast settings keys
339397340340-```sh
341341-snapserver --config /usr/local/etc/snapserver.conf
342342-```
398398+| Key | Type | Default | Sink | Description |
399399+|----------------------|--------|-----------------------|-------|------------------------------------------|
400400+| `audio_output` | string | `"builtin"` | both | `"fifo"` or `"snapcast_tcp"` |
401401+| `fifo_path` | string | `"/tmp/rockbox.fifo"` | FIFO | FIFO path, or `"-"` for stdout |
402402+| `snapcast_tcp_host` | string | — | TCP | IP / hostname of the snapserver machine |
403403+| `snapcast_tcp_port` | u16 | `4953` | TCP | snapserver TCP source port |
343404344405---
345406346407## Other pipe consumers
347408348348-Since the FIFO carries raw S16LE stereo 44100 Hz PCM, it works with any tool
349349-that accepts that format:
409409+Since both sinks carry raw S16LE stereo 44100 Hz PCM, the FIFO sink works with
410410+any tool that accepts that format:
350411351412```sh
352352-# Play directly with ffplay
413413+# Play directly with ffplay (stdout mode)
353414rockboxd | ffplay -f s16le -ar 44100 -ac 2 -
354415355355-# Encode on the fly with ffmpeg
416416+# Encode on the fly
356417rockboxd | ffmpeg -f s16le -ar 44100 -ac 2 -i - output.mp3
357418358419# Play with sox
···362423rockboxd | aplay -f S16_LE -r 44100 -c 2
363424```
364425365365-All of these require `fifo_path = "-"` in `settings.toml` so rockboxd writes
366366-to stdout.
426426+All of these require `fifo_path = "-"` and are only available with the FIFO
427427+sink. The TCP sink does not support stdout mode.
367428368429---
369430370431## Gotchas and known limits
371432372372-### 1. Startup order is critical
433433+### 1. Startup order is critical for both sinks
373434374374-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.
435435+- **FIFO**: rockboxd must open the FIFO before snapserver. Reverse order causes
436436+ snapserver to hold the only writer reference; when it closes, readers see EOF.
437437+ Restart snapserver if this happens.
438438+- **TCP**: snapserver must be listening before playback starts. If snapserver
439439+ is not yet running, `sink_dma_start` logs a warning and drops audio for that
440440+ buffer. It reconnects automatically on the next play call.
377441378442### 2. Fixed 44100 Hz, S16LE stereo
379443380380-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.
444444+Neither sink resamples. `set_freq` is a no-op. The firmware resamples tracks
445445+internally before they reach the sink, but the output is always 44100 Hz.
446446+Configure `sampleformat=44100:16:2` on the snapserver side.
384447385448### 3. No volume control through the sink
386449387450Volume 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.
451451+Adjust volume through the Rockbox API or client applications.
390452391453### 4. Consumer back-pressure controls playback speed
392454393393-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
455455+Both sinks use blocking `write()`. A slow or stalled consumer stalls
456456+`write()`, which stalls the DMA callback loop, which pauses decoding. This
457457+is correct for synchronized output but means a crashed consumer freezes
397458playback. Restart snapserver to recover.
398459399460### 5. macOS `snapserver.conf` vs CLI flag
400461401401-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).
462462+The `-s` flag to snapserver is silently ignored on macOS (≥ v0.35.0).
463463+Always use the config file for both `pipe://` and `tcp://` sources.
464464+465465+### 6. TCP reconnect drops in-flight buffer
466466+467467+When the write loop detects `EPIPE` it closes the socket immediately. The
468468+current audio buffer is discarded. Reconnection happens on the next
469469+`sink_dma_start()` call, so there will be a brief audio gap when snapserver
470470+restarts.
403471404404-### 6. Logging uses `tracing`, never `println!`
472472+### 7. Logging uses `tracing`, never `println!`
405473406474All 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.
475475+`eprintln!` bypass the log filter and — in stdout/FIFO mode — can corrupt the
476476+PCM stream. Use `RUST_LOG=debug rockboxd` to see debug output on stderr.
···332332 _ => return -1,
333333 };
334334335335- // Guard: never issue a Range request past EOF — the server would return 416
336336- // and seek_to would leave response=None, permanently breaking the stream.
337337- // This can happen when the C MP4 parser has a uint32_t underflow in its
338338- // atom-size arithmetic and tries to seek gigabytes forward.
335335+ // Guard 1: never seek gigabytes forward regardless of content_length.
336336+ // A seek >256 MB past current position is always a codec arithmetic bug
337337+ // (e.g. WAV trying to skip a 0xFFFFFFFF-byte data chunk, or MP4 with
338338+ // uint32_t underflow). Issue a Range request for such an offset and the
339339+ // server either returns 416 or streams from the beginning — either way
340340+ // skip_bytes would block for hours reading a live stream.
341341+ const MAX_SKIP: u64 = 256 * 1024 * 1024; // 256 MB
342342+ if new_pos > state.pos && new_pos - state.pos > MAX_SKIP {
343343+ warn!(
344344+ "[netstream] rb_net_lseek: h={} off={} whence={} huge skip ({} bytes) clamped",
345345+ h,
346346+ off,
347347+ whence,
348348+ new_pos - state.pos
349349+ );
350350+ return -1;
351351+ }
352352+353353+ // Guard 2: never issue a Range request past EOF — the server would return
354354+ // 416 and leave response=None, permanently breaking the stream.
339355 if let Some(cl) = state.content_length {
340356 if new_pos >= cl {
341357 warn!(
···45454646 let player_mutex = PLAYER_MUTEX.lock().unwrap();
47474848+ // For HTTP streams: flush the audio thread's message queue before replacing
4949+ // the playlist. Stale Q_AUDIO_FILL_BUFFER messages from a previous HTTP
5050+ // session (e.g. auto-resume) would otherwise act on the new playlist's
5151+ // handle with the old stream context, causing the new play to be silently
5252+ // ignored. For local files the queue is always drained by the time the
5353+ // user starts a new playlist, so hard_stop is a no-op cost there.
5454+ let current_is_http = rb::playback::current_track()
5555+ .map(|t| t.path.starts_with("http://") || t.path.starts_with("https://"))
5656+ .unwrap_or(false);
5757+ let new_is_http = new_playlist.tracks[0].starts_with("http://")
5858+ || new_playlist.tracks[0].starts_with("https://");
5959+ if current_is_http || new_is_http {
6060+ rb::playback::hard_stop();
6161+ }
6262+4863 // Always create a fresh playlist so the currently-playing track is
4964 // fully replaced rather than appended to.
5065 // Local paths: use the track's parent directory (required by Rockbox).
···308323async fn persist_remote_track_metadata(ctx: &Context, tracks: &[String]) -> Result<(), Error> {
309324 for track in tracks {
310325 if track.starts_with("http://") || track.starts_with("https://") {
326326+ // Raw PCM streams served at /stream.wav have no embedded metadata and
327327+ // probing them would block for ~47 s (8 MB at audio bitrate) then time out.
328328+ if reqwest::Url::parse(track)
329329+ .map(|u| u.path() == "/stream.wav")
330330+ .unwrap_or(false)
331331+ {
332332+ continue;
333333+ }
311334 if find_internal_track_by_url(ctx, track).await?.is_some() {
312335 continue;
313336 }
···9292 }
9393 }
94949595+ /// Start a subscriber `n` chunks *behind* the live edge so the remote
9696+ /// client can drain historical buffered data at network speed instead of
9797+ /// waiting for new chunks to arrive in real time. Clamped to the oldest
9898+ /// chunk still in the buffer.
9999+ pub(crate) fn subscribe_from_behind(self: &Arc<Self>, n: u64) -> BroadcastReceiver {
100100+ let g = self.inner.lock().unwrap();
101101+ let front_seq = g.chunks.front().map(|(s, _)| *s).unwrap_or(g.next_seq);
102102+ let next_seq = g.next_seq.saturating_sub(n).max(front_seq);
103103+ BroadcastReceiver {
104104+ buf: Arc::clone(self),
105105+ next_seq,
106106+ }
107107+ }
108108+95109 fn reset(&self) {
96110 let mut g = self.inner.lock().unwrap();
97111 g.chunks.clear();
···320334 if let Some(ref t) = track {
321335 *LAST_PCM_TRACK_PATH.lock().unwrap() = t.path.clone();
322336 }
323323- let album_art_url = if let Some(ref t) = track {
324324- get_album_art_url(&t.path, local_ip).await
325325- } else {
326326- None
327327- };
328328- if let Err(e) = avtransport_play(
329329- &url,
330330- &stream_url,
331331- track.as_ref(),
332332- sample_rate,
333333- true,
334334- album_art_url.as_deref(),
335335- )
336336- .await
337337+ // Send SetAVTransportURI + Play immediately without waiting for the
338338+ // album art DB lookup — this cuts 100–500 ms of startup latency.
339339+ if let Err(e) =
340340+ avtransport_play(&url, &stream_url, track.as_ref(), sample_rate, true, None)
341341+ .await
337342 {
338343 tracing::warn!("UPnP AVTransport play failed: {}", e);
344344+ return;
345345+ }
346346+ // Follow up with album art once the DB lookup completes (metadata-
347347+ // only update, no second Play so the renderer stays connected).
348348+ if let Some(ref t) = track {
349349+ if let Some(art) = get_album_art_url(&t.path, local_ip).await {
350350+ let _ = avtransport_play(
351351+ &url,
352352+ &stream_url,
353353+ track.as_ref(),
354354+ sample_rate,
355355+ false,
356356+ Some(&art),
357357+ )
358358+ .await;
359359+ }
339360 }
340361 });
341362 } else {
···357378 }
358379 };
359380360360- if is_track_change {
381381+ // If the "new track" is our own WAV stream the renderer is re-ingesting
382382+ // (http://<ip>:<port>/stream.wav), skip SetAVTransportURI entirely.
383383+ // Sending it would overwrite the correct DIDL metadata (duration, album
384384+ // art) stored for the real file with stream.wav's codec values (duration
385385+ // 0, no art), and would create an infinite feedback loop.
386386+ let is_our_stream =
387387+ current_path.starts_with("http://") && current_path.ends_with("/stream.wav");
388388+389389+ if is_track_change && !is_our_stream {
361390 // Fire SetAVTransportURI *without* Play at the exact PCM boundary.
362391 // The renderer updates its metadata display and — if it supports
363392 // metadata-only updates — stays connected to the live /stream.wav
···423452 *rp = false;
424453}
425454455455+/// Reset the renderer-side state so the next `pcm_upnp_start()` always sends
456456+/// a fresh SetAVTransportURI + Play to the renderer. Call this whenever the
457457+/// UPnP sink is selected (e.g. the user connects to a UPnP device from the UI)
458458+/// to make output switch-in work without restarting the daemon.
459459+#[cfg(feature = "ffi")]
460460+#[no_mangle]
461461+pub extern "C" fn pcm_upnp_reset_renderer() {
462462+ // Kill any running track-change monitor — a new one will start with pcm_upnp_start().
463463+ MONITOR_GEN.fetch_add(1, Ordering::SeqCst);
464464+ // Clear the last-track hint so the next start is always treated as "first play".
465465+ *LAST_PCM_TRACK_PATH.lock().unwrap() = String::new();
466466+ // Reset the "renderer already playing" flag so pcm_upnp_start() sends Play.
467467+ *RENDERER_PLAYING.lock().unwrap() = false;
468468+ tracing::debug!("UPnP PCM sink: renderer state reset");
469469+}
470470+426471// ---------------------------------------------------------------------------
427472// Album art DB lookup
428473// ---------------------------------------------------------------------------
···470515 .map(|t| t.path.clone())
471516 .unwrap_or_default();
472517473473- if !current_path.is_empty() && current_path != last_current_path {
518518+ let is_our_stream =
519519+ current_path.starts_with("http://") && current_path.ends_with("/stream.wav");
520520+521521+ if !current_path.is_empty() && current_path != last_current_path && !is_our_stream {
474522 let is_first = last_current_path.is_empty();
475523 last_current_path = current_path.clone();
476524
+5-1
crates/upnp/src/pcm_server.rs
···361361 if req.wants_icy { " (ICY)" } else { "" }
362362 );
363363364364- let mut rx = buf.subscribe();
364364+ // Start ~2 s behind the live edge so the renderer can drain historical
365365+ // buffered chunks at network speed instead of filling at real-time rate.
366366+ // Each chunk ≈ 8192 bytes ≈ 46 ms; 44 chunks ≈ 2 s.
367367+ let mut rx = buf.subscribe_from_behind(44);
365368366369 if req.wants_icy {
367370 let art_url = art_base_url(local_ip, port);
···476479 for stream in listener.incoming() {
477480 match stream {
478481 Ok(mut tcp) => {
482482+ let _ = tcp.set_nodelay(true);
479483 let buf = buf.clone();
480484 std::thread::spawn(move || {
481485 let peer = tcp.peer_addr().map(|a| a.to_string()).unwrap_or_default();
+158-27
crates/upnp/src/renderer.rs
···4141 current_metadata: String,
4242 transport_state: TransportState,
4343 mute: bool,
4444+ /// Value of `current_track().elapsed` when the current track started.
4545+ /// Subtracted from the live elapsed so RelTime resets to 0 on each track
4646+ /// change even though the underlying WAV stream is continuous.
4747+ elapsed_offset: u64,
4448}
45494650static RENDERER_STATE: Mutex<RendererState> = Mutex::new(RendererState {
···4852 current_metadata: String::new(),
4953 transport_state: TransportState::NoMediaPresent,
5054 mute: false,
5555+ elapsed_offset: 0,
5156});
52575358static RENDERER_UUID: OnceLock<String> = OnceLock::new();
···369374 tracing::info!("UPnP renderer: SetAVTransportURI = {uri}");
370375 {
371376 let mut st = RENDERER_STATE.lock().unwrap();
377377+ // Track-change while playing: capture current elapsed so that
378378+ // GetPositionInfo resets RelTime to 0 for the new track.
379379+ if st.transport_state == TransportState::Playing {
380380+ let elapsed = rockbox_sys::playback::current_track()
381381+ .map(|t| t.elapsed)
382382+ .unwrap_or(0);
383383+ st.elapsed_offset = elapsed;
384384+ }
372385 st.current_uri = Some(uri);
373386 st.current_metadata = metadata;
374387 st.transport_state = TransportState::Stopped;
···381394 }
382395383396 Some("Play") => {
384384- let uri = {
397397+ let (uri, metadata) = {
385398 let st = RENDERER_STATE.lock().unwrap();
386386- st.current_uri.clone()
399399+ (st.current_uri.clone(), st.current_metadata.clone())
387400 };
401401+ tracing::info!("UPnP renderer: Play action received, uri={uri:?}");
388402 match uri {
389403 None => soap_error(701, "Transition not available"),
390404 Some(uri) => {
391405 tracing::info!("UPnP renderer: Play {uri}");
406406+407407+ // Guard against the self-referential loop: when the UPnP PCM
408408+ // output sink sends Play(stream.wav) to the same rockboxd
409409+ // instance's renderer, playing the stream back would create a
410410+ // circular audio path (engine reads stream.wav → PCM output →
411411+ // BroadcastBuffer → stream.wav → ...), overflowing the codec
412412+ // thread stack and crashing. Detect this by matching our own
413413+ // PCM HTTP port in the URI. Audio is already flowing via the
414414+ // UPnP sink, so just acknowledge without re-routing.
415415+ let own_pcm_port = crate::CONFIG.lock().unwrap().pcm_port;
416416+ let is_own_stream = (uri.ends_with("/stream.wav"))
417417+ && (uri.contains(&format!(":{own_pcm_port}/"))
418418+ || uri.ends_with(&format!(":{own_pcm_port}")));
419419+ if is_own_stream {
420420+ tracing::warn!(
421421+ "UPnP renderer: Play for own PCM stream (port {own_pcm_port}) — \
422422+ acknowledging without re-routing to prevent feedback loop"
423423+ );
424424+ RENDERER_STATE.lock().unwrap().transport_state = TransportState::Playing;
425425+ return soap_ok("urn:schemas-upnp-org:service:AVTransport:1", "Play", "");
426426+ }
427427+428428+ let title =
429429+ extract_didl_field(&metadata, "title").unwrap_or_else(|| uri.clone());
430430+ let artist = extract_didl_field(&metadata, "artist")
431431+ .or_else(|| extract_didl_field(&metadata, "creator"))
432432+ .unwrap_or_default();
433433+ let album = extract_didl_field(&metadata, "album").unwrap_or_default();
434434+ let duration_ms = parse_didl_duration_ms(&metadata).unwrap_or(0) as u64;
435435+ let album_art_url =
436436+ extract_didl_field(&metadata, "albumArtURI").unwrap_or_default();
437437+ rockbox_sys::playback::set_metadata_override(
438438+ &uri,
439439+ &title,
440440+ &artist,
441441+ &album,
442442+ duration_ms,
443443+ &album_art_url,
444444+ );
392445 play_url(&uri).await;
393393- RENDERER_STATE.lock().unwrap().transport_state = TransportState::Playing;
446446+ {
447447+ let mut st = RENDERER_STATE.lock().unwrap();
448448+ st.transport_state = TransportState::Playing;
449449+ st.elapsed_offset = 0; // stream restarts from 0
450450+ }
394451 soap_ok("urn:schemas-upnp-org:service:AVTransport:1", "Play", "")
395452 }
396453 }
···407464 }
408465 {
409466 let mut st = RENDERER_STATE.lock().unwrap();
467467+ if let Some(ref uri) = st.current_uri {
468468+ rockbox_sys::playback::clear_metadata_override(uri);
469469+ }
410470 st.transport_state = TransportState::Stopped;
411471 st.current_metadata = String::new();
412472 }
···521581 )
522582 }
523583524524- _ => soap_error(401, "Invalid Action"),
584584+ other => {
585585+ tracing::warn!("UPnP renderer: unknown AVTransport action: {other:?}");
586586+ soap_error(401, "Invalid Action")
587587+ }
525588 }
526589}
527590···637700 soap_error(401, "Invalid Action")
638701}
639702703703+/// Extract a DIDL-Lite field by its local name, trying common namespace prefixes.
704704+fn extract_didl_field(didl: &str, local_name: &str) -> Option<String> {
705705+ for prefix in &["dc:", "upnp:", "r:", ""] {
706706+ let tag = format!("{}{}", prefix, local_name);
707707+ if let Some(v) = extract_tag(didl, &tag) {
708708+ if !v.is_empty() {
709709+ return Some(v);
710710+ }
711711+ }
712712+ }
713713+ None
714714+}
715715+640716async fn play_url(uri: &str) {
641641- use crate::api::rockbox::v1alpha1::{
642642- playback_service_client::PlaybackServiceClient, PlayTrackRequest,
643643- };
644644- let port = std::env::var("ROCKBOX_PORT").unwrap_or_else(|_| "6061".to_string());
645645- let url = format!("tcp://127.0.0.1:{}", port);
646646- match PlaybackServiceClient::connect(url).await {
647647- Ok(mut client) => {
648648- let req = PlayTrackRequest {
649649- path: uri.to_string(),
650650- };
651651- if let Err(e) = client.play_track(req).await {
652652- tracing::error!("UPnP renderer: PlayTrack gRPC error: {e}");
653653- }
717717+ let tcp_port = std::env::var("ROCKBOX_TCP_PORT").unwrap_or_else(|_| "6063".to_string());
718718+ let base = format!("http://127.0.0.1:{tcp_port}");
719719+ // The internal HTTP server closes the TCP socket after each response.
720720+ // pool_max_idle_per_host(0) disables connection pooling so each request
721721+ // opens a fresh connection instead of reusing a server-closed socket.
722722+ let client = reqwest::Client::builder()
723723+ .pool_max_idle_per_host(0)
724724+ .build()
725725+ .unwrap_or_default();
726726+727727+ let body = serde_json::json!({ "tracks": [uri] });
728728+ tracing::info!("UPnP renderer: POST {base}/playlists tracks=[{uri}]");
729729+ match client
730730+ .post(format!("{base}/playlists"))
731731+ .json(&body)
732732+ .send()
733733+ .await
734734+ {
735735+ Ok(r) if r.status().is_success() => {
736736+ tracing::info!("UPnP renderer: playlist created, starting playback");
654737 }
655655- Err(e) => tracing::error!("UPnP renderer: gRPC connect error: {e}"),
738738+ Ok(r) => {
739739+ tracing::error!("UPnP renderer: POST /playlists failed: {}", r.status());
740740+ return;
741741+ }
742742+ Err(e) => {
743743+ tracing::error!("UPnP renderer: POST /playlists error: {e}");
744744+ return;
745745+ }
746746+ }
747747+748748+ tracing::info!("UPnP renderer: PUT {base}/playlists/start");
749749+ match client.put(format!("{base}/playlists/start")).send().await {
750750+ Ok(r) if r.status().is_success() => {
751751+ tracing::info!("UPnP renderer: playback started");
752752+ }
753753+ Ok(r) => tracing::error!("UPnP renderer: PUT /playlists/start failed: {}", r.status()),
754754+ Err(e) => tracing::error!("UPnP renderer: PUT /playlists/start error: {e}"),
656755 }
657756}
658757···673772}
674773675774fn get_position_info() -> (String, i32, i32) {
676676- let uri = RENDERER_STATE
677677- .lock()
678678- .unwrap()
679679- .current_uri
680680- .clone()
681681- .unwrap_or_default();
775775+ let (uri, metadata, elapsed_offset) = {
776776+ let st = RENDERER_STATE.lock().unwrap();
777777+ (
778778+ st.current_uri.clone().unwrap_or_default(),
779779+ st.current_metadata.clone(),
780780+ st.elapsed_offset,
781781+ )
782782+ };
682783683683- let (elapsed, duration) = match rockbox_sys::playback::current_track() {
684684- Some(track) => (track.elapsed as i32, track.length as i32),
685685- None => (0, 0),
784784+ // Elapsed from the codec minus the offset captured at the last track
785785+ // boundary, so RelTime resets to 0 on each track change even though the
786786+ // underlying WAV stream is continuous.
787787+ let elapsed = match rockbox_sys::playback::current_track() {
788788+ Some(track) => (track.elapsed.saturating_sub(elapsed_offset)) as i32,
789789+ None => 0,
686790 };
791791+792792+ // Duration from the DIDL-Lite sent by the source (accurate per-track).
793793+ let duration = parse_didl_duration_ms(&metadata).unwrap_or(0);
794794+687795 (uri, elapsed, duration)
796796+}
797797+798798+/// Parse `duration="H:MM:SS.mmm"` from a DIDL-Lite string.
799799+fn parse_didl_duration_ms(didl: &str) -> Option<i32> {
800800+ let key = "duration=\"";
801801+ let start = didl.find(key)? + key.len();
802802+ let end = didl[start..].find('"')? + start;
803803+ parse_upnp_duration_ms(&didl[start..end])
804804+}
805805+806806+/// Parse a UPnP duration string `[H+]:MM:SS[.F+]` into milliseconds.
807807+fn parse_upnp_duration_ms(s: &str) -> Option<i32> {
808808+ let mut parts = s.splitn(3, ':');
809809+ let h: i32 = parts.next()?.parse().ok()?;
810810+ let m: i32 = parts.next()?.parse().ok()?;
811811+ let sec_frac = parts.next()?;
812812+ let mut sec_parts = sec_frac.splitn(2, '.');
813813+ let sec: i32 = sec_parts.next()?.parse().ok()?;
814814+ let ms: i32 = sec_parts.next().map_or(0, |f| {
815815+ let padded = format!("{:0<3}", &f[..f.len().min(3)]);
816816+ padded.parse().unwrap_or(0)
817817+ });
818818+ Some((h * 3600 + m * 60 + sec) * 1000 + ms)
688819}
689820690821// SOUND_VOLUME = 0 in Rockbox (first entry in the sound settings enum).
···364364 fmt.numbytes = chunksize;
365365 if (fmt.formattag == WAVE_FORMAT_ATRAC3)
366366 id3->first_frame_offset = offset;
367367+ /* 'data' is always the last meaningful chunk. Stop here so the
368368+ * parser never tries to seek past an infinite-size data chunk
369369+ * (e.g. live WAV streams use 0xFFFFFFFF as a sentinel size). */
370370+ break;
367371 }
368372 else if (memcmp(buf, chunknames + LIST_CHUNK * namelen, namelen) == 0)
369373 {