···16166. [Layer 4 — Broadcast buffer (Rust)](#layer-4--broadcast-buffer-rust)
17177. [Layer 5 — HTTP stream server (Rust)](#layer-5--http-stream-server-rust)
18188. [Layer 6 — Slim Protocol server (Rust)](#layer-6--slim-protocol-server-rust)
1919-9. [Layer 7 — squeezelite client](#layer-7--squeezelite-client)
2020-10. [Startup sequence](#startup-sequence)
2121-11. [Track transition](#track-transition)
2222-12. [Multi-room](#multi-room)
2323-13. [Configuration](#configuration)
2424-14. [Gotchas and non-obvious invariants](#gotchas-and-non-obvious-invariants)
1919+9. [Layer 7 — Sync broadcaster (Rust)](#layer-7--sync-broadcaster-rust)
2020+10. [Layer 8 — squeezelite client](#layer-8--squeezelite-client)
2121+11. [Startup sequence](#startup-sequence)
2222+12. [Track transition](#track-transition)
2323+13. [Multi-room](#multi-room)
2424+14. [Configuration](#configuration)
2525+15. [Gotchas and non-obvious invariants](#gotchas-and-non-obvious-invariants)
25262627---
2728···4445 │
4546 ├── HTTP server :9999 ─── one thread per squeezelite client
4647 │
4747- └── Slim server :3483 ─── sends STRM, keeps connection alive
4848+ └── Slim server :3483 ─── sends STRM + audg keepalive + sync jiffies
4949+ │
5050+ └── SyncBroadcaster ─── broadcasts same jiffies to all clients/s
4851```
49525053The PCM data is **never transcoded** — rockboxd pushes raw signed 16-bit
···7174┌─────────────────────────────────────────────────────────────────┐
7275│ rockbox-slim crate (Rust) │
7376│ │
7474-│ BroadcastBuffer │
7575-│ ┌──────────────────────────────────────────────────────────┐ │
7676-│ │ VecDeque<(seq: u64, chunk: Vec<u8>)> max 4 MB │ │
7777-│ │ next_seq (writer cursor) │ │
7878-│ └──────────────────────────────────────────────────────────┘ │
7979-│ │ │ │
8080-│ HTTP server :9999 Slim Protocol server :3483 │
8181-│ (one thread per client) (one thread per client) │
8282-│ │ │ │
8383-│ BroadcastReceiver HELO → STRM → audg keepalive │
8484-│ (per-client seq cursor) │
7777+│ BroadcastBuffer SyncBroadcaster │
7878+│ ┌───────────────────────────┐ ┌──────────────────────┐ │
7979+│ │ VecDeque<(seq,chunk)> │ │ Vec<Sender<u32>> │ │
8080+│ │ max 4 MB │ │ → server_jiffies/1 s │ │
8181+│ └───────────────────────────┘ └──────────────────────┘ │
8282+│ │ │ │
8383+│ HTTP server :9999 Slim Protocol server :3483 │
8484+│ (one thread per client) (one thread per client) │
8585+│ │ │ │ │
8686+│ BroadcastReceiver audg keepalive sync jiffies=T │
8787+│ (per-client seq cursor) (on STMt) (every 1 s) │
8588└────────────────────────────┬────────────────────────────────────┘
8689 │ TCP :9999
8790 ┌──────────────┼──────────────┐
···364367365368### Session flow
366369370370+Two server-to-client message streams run concurrently on the same TCP
371371+connection: the **read loop** replies to `STMt` heartbeats with `audg`, while
372372+the **sync writer thread** independently sends `sync` once per second. Writes
373373+are serialised through an `Arc<Mutex<TcpStream>>` clone.
374374+367375```
368368-squeezelite Slim server
369369-─────────── ───────────
370370-HELO ──────────────────────────────►
371371- (no kick — all clients share the stream)
372372- ◄────────────────────────── STRM 's'
376376+squeezelite Slim server
377377+─────────── ─────────────────────────────────
378378+HELO ────────────────────────────►
379379+ ◄─────────────────────────── STRM 's'
373380HTTP GET :9999/stream.pcm ─────────────────────────────► HTTP server
374374- ◄────────────────────── HTTP 200 + raw PCM stream
375375-STAT STMc ─────────────────────────►
376376-STAT STMs ─────────────────────────►
377377-STAT STMt ─────────────────────────► (every ~1 s)
378378- ◄─────────────────────────── audg (full volume) ← keepalive
379379-STAT STMt ─────────────────────────►
380380- ◄─────────────────────────── audg
381381- …
381381+ ◄─────────────────────── HTTP 200 + raw PCM stream
382382+STAT STMc ───────────────────────►
383383+STAT STMs ───────────────────────►
384384+ [sync broadcaster fires at t=1 s]
385385+ ◄─────────────────────────── sync jiffies=T ← clock alignment
386386+STAT STMt ───────────────────────► (~1 s heartbeat)
387387+ ◄─────────────────────────── audg ← watchdog reset
388388+ [sync broadcaster fires at t=2 s]
389389+ ◄─────────────────────────── sync jiffies=T+1000
390390+STAT STMt ───────────────────────►
391391+ ◄─────────────────────────── audg
392392+ …
382393```
394394+395395+`sync` and `audg` are sent on different triggers (`sync` from a shared timer
396396+thread; `audg` from the per-client read loop) and can arrive in either order
397397+within the same second.
383398384399### STRM 's' payload layout
385400···432447433448This resets squeezelite's timeout counter to zero on every tick.
434449450450+### sync packet
451451+452452+```
453453+Offset Size Value Meaning
454454+────── ──── ───── ────────────────────────────────────────────────────
455455+ 0 4 BE u32 server jiffies (ms since Unix epoch, truncated to u32)
456456+```
457457+458458+squeezelite uses the jiffies value to compute how far ahead or behind it is
459459+relative to the server clock, then adjusts its output buffer drain rate to
460460+converge. Because all clients receive the **same jiffies value at the same
461461+instant**, they converge to the same playback position.
462462+435463---
436464437437-## Layer 7 — squeezelite client
465465+## Layer 7 — Sync broadcaster (Rust)
466466+467467+**File:** `crates/slim/src/lib.rs`
468468+469469+### Design
470470+471471+`SyncBroadcaster` is a `Mutex<Vec<mpsc::Sender<u32>>>`. It is stored in a
472472+`static OnceLock<Arc<SyncBroadcaster>>` so both `slimproto::serve` and the
473473+background timer thread share the same instance.
474474+475475+```rust
476476+pub(crate) struct SyncBroadcaster {
477477+ senders: Mutex<Vec<mpsc::Sender<u32>>>,
478478+}
479479+```
480480+481481+### subscribe
482482+483483+Called from `slimproto::serve` for each new client, **before** the client
484484+handler thread is spawned:
485485+486486+```rust
487487+pub(crate) fn subscribe(&self) -> mpsc::Receiver<u32> {
488488+ let (tx, rx) = mpsc::channel();
489489+ self.senders.lock().unwrap().push(tx);
490490+ rx
491491+}
492492+```
493493+494494+The `Receiver` is moved into the client thread. When the client disconnects,
495495+the thread exits, the `Receiver` is dropped, and the `Sender` is pruned from
496496+the list on the next `broadcast()` call.
497497+498498+### broadcast
499499+500500+```rust
501501+pub(crate) fn broadcast(&self, jiffies: u32) {
502502+ let mut senders = self.senders.lock().unwrap();
503503+ senders.retain(|tx| tx.send(jiffies).is_ok());
504504+}
505505+```
506506+507507+`retain` removes any `Sender` whose `Receiver` has been dropped (i.e. whose
508508+client has disconnected), keeping the list compact.
509509+510510+### Timer thread
511511+512512+Started once inside `pcm_squeezelite_start()`:
513513+514514+```rust
515515+let sync = get_sync();
516516+std::thread::spawn(move || loop {
517517+ std::thread::sleep(Duration::from_secs(1));
518518+ sync.broadcast(server_jiffies());
519519+});
520520+```
521521+522522+`server_jiffies()` returns `SystemTime::now().as_millis() as u32`. The u32
523523+truncation gives a ~49-day rollover; squeezelite handles this correctly with
524524+signed 32-bit arithmetic.
525525+526526+### Per-client sync writer thread
527527+528528+Each `handle_client` call clones the TCP write stream into an
529529+`Arc<Mutex<TcpStream>>` shared with a dedicated sync writer thread:
530530+531531+```rust
532532+let write_stream = Arc::new(Mutex::new(stream.try_clone()?));
533533+534534+let ws = Arc::clone(&write_stream);
535535+std::thread::spawn(move || {
536536+ for jiffies in sync_rx { // blocks until broadcaster fires
537537+ let mut s = ws.lock().unwrap();
538538+ send_sync(&mut *s, jiffies)?; // write `sync` packet
539539+ }
540540+});
541541+```
542542+543543+The read loop uses the same `write_stream` mutex to send `audg` replies, so
544544+`sync` and `audg` packets never interleave at the byte level.
545545+546546+---
547547+548548+## Layer 8 — squeezelite client
438549439550squeezelite handles the audio pipeline in three internal threads:
440551···468579 └── sink_dma_start(addr, size) [pcm-squeezelite.c]
469580 │
470581 ├── pcm_squeezelite_start() [lib.rs — idempotent]
582582+ │ ├── spawn sync broadcaster thread (fires every 1 s)
471583 │ ├── spawn HTTP server thread on :9999
472584 │ └── spawn Slim server thread on :3483
473585 │
···480592 ├── send HELO
481593 ├── receive STRM 's' → TCP connect :9999
482594 ├── receive HTTP 200
483483- └── start buffering PCM
595595+ ├── start buffering PCM
596596+ └── receive sync jiffies=T every ~1 s → align playback clock
484597```
485598486599---
···513626- A slow reader skips forward to the oldest available chunk; other readers
514627 are unaffected.
515628516516-Squeezelite clients are not time-synchronised (no NTP/PTP layer). Clock drift
517517-between rooms is typically 100–500 ms. For tighter sync, run squeezelite with
518518-a Snapcast-compatible back-end or use the FIFO sink instead.
629629+### Clock synchronisation
630630+631631+Once per second the `SyncBroadcaster` computes `server_jiffies()` once and
632632+delivers the **same value** to every connected client via `mpsc` channels.
633633+Each client's sync writer thread immediately sends a `sync` packet over its
634634+individual TCP connection.
635635+636636+squeezelite's internal sync handler:
637637+1. Receives `sync jiffies=T`.
638638+2. Computes `delta = T - my_jiffies + output_buffer_latency`.
639639+3. Adjusts its buffer drain rate (speeds up or slows down slightly) to converge
640640+ on the target position.
641641+642642+Because all rooms receive the same `T`, they converge to the same audio
643643+position. In practice this achieves sub-100 ms synchronisation over a LAN,
644644+which is imperceptible to human listeners.
519645520646---
521647
+57-1
crates/slim/src/lib.rs
···66pub fn _link_slim() {}
7788use std::collections::VecDeque;
99-use std::sync::{Arc, Condvar, Mutex, OnceLock};
99+use std::sync::{mpsc, Arc, Condvar, Mutex, OnceLock};
1010+use std::time::{Duration, SystemTime, UNIX_EPOCH};
10111112// ---------------------------------------------------------------------------
1213// Broadcast buffer — one writer, N independent readers.
···146147 http_port: 9999,
147148});
148149150150+// ---------------------------------------------------------------------------
151151+// Sync broadcaster — sends the same jiffies value to all connected clients
152152+// once per second so squeezelite instances converge to the same playback clock.
153153+// ---------------------------------------------------------------------------
154154+155155+pub(crate) struct SyncBroadcaster {
156156+ senders: Mutex<Vec<mpsc::Sender<u32>>>,
157157+}
158158+159159+impl SyncBroadcaster {
160160+ fn new() -> Self {
161161+ SyncBroadcaster {
162162+ senders: Mutex::new(Vec::new()),
163163+ }
164164+ }
165165+166166+ /// Register a new client receiver. Returns the Receiver end of the channel.
167167+ pub(crate) fn subscribe(&self) -> mpsc::Receiver<u32> {
168168+ let (tx, rx) = mpsc::channel();
169169+ self.senders.lock().unwrap().push(tx);
170170+ rx
171171+ }
172172+173173+ /// Broadcast jiffies to all clients, pruning senders whose client has gone.
174174+ pub(crate) fn broadcast(&self, jiffies: u32) {
175175+ let mut senders = self.senders.lock().unwrap();
176176+ senders.retain(|tx| tx.send(jiffies).is_ok());
177177+ }
178178+}
179179+180180+static SYNC: OnceLock<Arc<SyncBroadcaster>> = OnceLock::new();
181181+182182+pub(crate) fn get_sync() -> Arc<SyncBroadcaster> {
183183+ SYNC.get_or_init(|| Arc::new(SyncBroadcaster::new())).clone()
184184+}
185185+186186+/// Milliseconds since the Unix epoch, truncated to u32 (~49-day rollover).
187187+/// Sent to all squeezelite clients so they can align their playback clocks.
188188+fn server_jiffies() -> u32 {
189189+ SystemTime::now()
190190+ .duration_since(UNIX_EPOCH)
191191+ .unwrap_or_default()
192192+ .as_millis() as u32
193193+}
194194+149195fn get_buffer() -> Arc<BroadcastBuffer> {
150196 BUFFER
151197 .get_or_init(|| Arc::new(BroadcastBuffer::new()))
···181227182228 let buf = get_buffer();
183229 buf.reset();
230230+231231+ // Sync broadcaster: computes jiffies once per second and fans out to all
232232+ // connected clients so they align to the same playback clock reference.
233233+ {
234234+ let sync = get_sync();
235235+ std::thread::spawn(move || loop {
236236+ std::thread::sleep(Duration::from_secs(1));
237237+ sync.broadcast(server_jiffies());
238238+ });
239239+ }
184240185241 let buf_http = buf.clone();
186242 std::thread::spawn(move || http::serve(http_port, buf_http));
+110-13
crates/slim/src/slimproto.rs
···11use std::io::{Read, Write};
22use std::net::{TcpListener, TcpStream};
33+use std::sync::{mpsc, Arc, Mutex};
3445/// Slim Protocol TCP server. Each squeezelite instance that connects gets a
56/// STRM command pointing at our HTTP broadcast endpoint. Multiple clients are
66-/// fully supported — each connects to the same HTTP port and receives its own
77-/// independent read cursor into the shared PCM broadcast buffer.
77+/// fully supported — each receives an independent BroadcastReceiver cursor into
88+/// the shared PCM buffer plus a per-second `sync` command so all instances
99+/// align to the same server jiffies reference.
810pub fn serve(slim_port: u16, http_port: u16) {
911 let listener = match TcpListener::bind(("0.0.0.0", slim_port)) {
1012 Ok(l) => l,
···1820 for stream in listener.incoming() {
1921 match stream {
2022 Ok(stream) => {
2121- std::thread::spawn(move || handle_client(stream, http_port));
2323+ // Subscribe before spawning so the sender is registered
2424+ // before the first sync broadcast fires.
2525+ let sync_rx = crate::get_sync().subscribe();
2626+ std::thread::spawn(move || handle_client(stream, http_port, sync_rx));
2227 }
2328 Err(e) => tracing::warn!("slim: accept error: {e}"),
2429 }
2530 }
2631}
27322828-fn handle_client(mut stream: TcpStream, http_port: u16) {
3333+fn handle_client(mut stream: TcpStream, http_port: u16, sync_rx: mpsc::Receiver<u32>) {
2934 let peer = stream
3035 .peer_addr()
3136 .map(|a| a.to_string())
···5257 }
5358 tracing::info!("slim: sent STRM to {peer} → http stream on :{http_port}");
54595555- // Read STAT / DSCO packets. Reply to every STMt heartbeat with audg so
5656- // squeezelite's 36-second "no messages from server" watchdog never fires.
6060+ // Clone the stream for writes; reads stay on the original fd.
6161+ // Both fds refer to the same socket — POSIX guarantees this is safe.
6262+ let write_stream = match stream.try_clone() {
6363+ Ok(s) => Arc::new(Mutex::new(s)),
6464+ Err(e) => {
6565+ tracing::error!("slim: try_clone failed for {peer}: {e}");
6666+ return;
6767+ }
6868+ };
6969+7070+ // Sync writer thread: receives jiffies from the broadcaster and forwards
7171+ // `sync` packets to this client so it aligns with the server clock.
7272+ // Runs concurrently with the read loop below, sharing write_stream.
7373+ {
7474+ let ws = Arc::clone(&write_stream);
7575+ let peer_label = peer.clone();
7676+ std::thread::spawn(move || {
7777+ for jiffies in sync_rx {
7878+ let mut s = ws.lock().unwrap();
7979+ if let Err(e) = send_sync(&mut *s, jiffies) {
8080+ tracing::debug!("slim: sync write error to {peer_label}: {e}");
8181+ break;
8282+ }
8383+ tracing::debug!("slim: sync jiffies={jiffies} → {peer_label}");
8484+ }
8585+ });
8686+ }
8787+8888+ // Read loop: handle STAT / DSCO packets.
8989+ // Reply to every STMt heartbeat with `audg` to keep squeezelite's 36-second
9090+ // watchdog from firing. Log timing data for diagnostics.
5791 loop {
5892 match read_client_packet(&mut stream) {
5993 Ok((opcode, body)) => {
6094 if opcode == "STAT" && body.len() >= 4 {
6195 let ev = std::str::from_utf8(&body[..4]).unwrap_or("????");
6262- tracing::debug!("slim: STAT {ev} from {peer}");
6396 if ev == "STMt" {
6464- if let Err(e) = send_audg(&mut stream) {
9797+ let elapsed_ms = stmt_elapsed_ms(&body);
9898+ let client_jiffies = stmt_jiffies(&body);
9999+ tracing::debug!(
100100+ "slim: STMt from {peer}: elapsed={elapsed_ms}ms \
101101+ client_jiffies={client_jiffies}"
102102+ );
103103+ let mut s = write_stream.lock().unwrap();
104104+ if let Err(e) = send_audg(&mut *s) {
65105 tracing::debug!("slim: audg error to {peer}: {e}");
66106 break;
67107 }
108108+ } else {
109109+ tracing::debug!("slim: STAT {ev} from {peer}");
68110 }
69111 } else if opcode == "DSCO" {
70112 tracing::info!("slim: DSCO from {peer}");
···80122 }
81123 }
82124}
125125+126126+// ---------------------------------------------------------------------------
127127+// Packet I/O helpers
128128+// ---------------------------------------------------------------------------
8312984130fn read_client_packet(stream: &mut TcpStream) -> std::io::Result<(String, Vec<u8>)> {
85131 let mut opcode = [0u8; 4];
···114160 payload.push(b's'); // command: start
115161 payload.push(b'1'); // autostart
116162 payload.push(b'p'); // format: raw PCM
117117- payload.push(b'1'); // pcm_sample_size: 16-bit
118118- payload.push(b'3'); // pcm_sample_rate: 44100 Hz
119119- payload.push(b'2'); // pcm_channels: stereo
120120- payload.push(b'1'); // pcm_endianness: little-endian
163163+ payload.push(b'1'); // pcm_sample_size: 16-bit (squeezelite: field - '0')
164164+ payload.push(b'3'); // pcm_sample_rate: 44100 (squeezelite: field - '0')
165165+ payload.push(b'2'); // pcm_channels: stereo (squeezelite: field - '0')
166166+ payload.push(b'1'); // pcm_endianness: LE (squeezelite: field - '0')
121167 payload.push(255u8); // threshold: 255 KB
122168 payload.push(0u8); // spdif_enable
123169 payload.push(0u8); // transition_period
···127173 payload.push(0u8); // slaves
128174 payload.extend_from_slice(&0x00010000u32.to_be_bytes()); // replay_gain = 1.0
129175 payload.extend_from_slice(&http_port.to_be_bytes());
130130- payload.extend_from_slice(&0u32.to_be_bytes()); // server_ip = 0 → use slimproto_ip
176176+ payload.extend_from_slice(&0u32.to_be_bytes()); // server_ip = 0 → use slimproto IP
131177 payload.extend_from_slice(request);
132178 send_server_packet(stream, b"strm", &payload)
133179}
134180181181+/// `audg` — full-volume gain packet; sent on every STMt heartbeat to suppress
182182+/// squeezelite's 36-second "no messages from server" watchdog.
135183fn send_audg(stream: &mut TcpStream) -> std::io::Result<()> {
136184 let mut payload = [0u8; 9];
137185 payload[0..4].copy_from_slice(&0x00010000u32.to_be_bytes()); // left gain = 1.0
138186 payload[4..8].copy_from_slice(&0x00010000u32.to_be_bytes()); // right gain = 1.0
139187 send_server_packet(stream, b"audg", &payload)
140188}
189189+190190+/// `sync` — tells squeezelite to align its playback clock to `jiffies`.
191191+/// All clients receive the same value from the broadcaster, causing them to
192192+/// converge to the same audio position.
193193+fn send_sync(stream: &mut TcpStream, jiffies: u32) -> std::io::Result<()> {
194194+ send_server_packet(stream, b"sync", &jiffies.to_be_bytes())
195195+}
196196+197197+// ---------------------------------------------------------------------------
198198+// STMt body parsers
199199+//
200200+// STMt body layout (all fields big-endian):
201201+// [0..4] event ("STMt")
202202+// [4] num_crlf
203203+// [5] mas_initialized
204204+// [6] mas_mode
205205+// [7..11] rptr (stream buffer read pointer)
206206+// [11..15] wptr (stream buffer write pointer)
207207+// [15..23] bytes_received (u64)
208208+// [23..25] signal_strength (u16)
209209+// [25..29] jiffies (u32) ← client's monotonic ms clock
210210+// [29..33] output_buffer_size
211211+// [33..37] output_buffer_fullness
212212+// [37..41] elapsed_seconds
213213+// [41..43] voltage (u16)
214214+// [43..47] elapsed_milliseconds ← ms of audio output so far
215215+// [47..51] server_timestamp (echo of last strm timestamp)
216216+// [51..53] error_code (u16)
217217+// ---------------------------------------------------------------------------
218218+219219+fn stmt_jiffies(body: &[u8]) -> u32 {
220220+ read_u32_be(body, 25)
221221+}
222222+223223+fn stmt_elapsed_ms(body: &[u8]) -> u32 {
224224+ read_u32_be(body, 43)
225225+}
226226+227227+fn read_u32_be(data: &[u8], offset: usize) -> u32 {
228228+ if data.len() < offset + 4 {
229229+ return 0;
230230+ }
231231+ u32::from_be_bytes([
232232+ data[offset],
233233+ data[offset + 1],
234234+ data[offset + 2],
235235+ data[offset + 3],
236236+ ])
237237+}