···11+# rockbox-slim — Squeezelite PCM sink internals
22+33+This document traces the full path of a PCM audio frame from the Rockbox C
44+firmware through the Slim Protocol and HTTP broadcast layer to one or more
55+squeezelite instances.
66+77+---
88+99+## Table of contents
1010+1111+1. [Overview](#overview)
1212+2. [Architecture diagram](#architecture-diagram)
1313+3. [Layer 1 — PCM sink abstraction (C)](#layer-1--pcm-sink-abstraction-c)
1414+4. [Layer 2 — DMA thread and real-time pacing (C)](#layer-2--dma-thread-and-real-time-pacing-c)
1515+5. [Layer 3 — FFI boundary](#layer-3--ffi-boundary)
1616+6. [Layer 4 — Broadcast buffer (Rust)](#layer-4--broadcast-buffer-rust)
1717+7. [Layer 5 — HTTP stream server (Rust)](#layer-5--http-stream-server-rust)
1818+8. [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)
2525+2626+---
2727+2828+## Overview
2929+3030+The squeezelite sink turns rockboxd into a minimal Logitech Media Server (LMS)
3131+that speaks just enough of the Slim Protocol to command squeezelite clients to
3232+fetch a continuous raw PCM stream over HTTP.
3333+3434+```
3535+rockboxd squeezelite
3636+───────── ───────────
3737+Codec → PCM engine audio device (CoreAudio / ALSA / …)
3838+ │ ▲
3939+ pcm-squeezelite.c (C) │
4040+ │ │
4141+ pcm_squeezelite_write() [HTTP audio stream]
4242+ │ │
4343+ BroadcastBuffer (Rust) ←── pcm_squeezelite_write() pushes chunks
4444+ │
4545+ ├── HTTP server :9999 ─── one thread per squeezelite client
4646+ │
4747+ └── Slim server :3483 ─── sends STRM, keeps connection alive
4848+```
4949+5050+The PCM data is **never transcoded** — rockboxd pushes raw signed 16-bit
5151+little-endian stereo (`S16LE`) at 44 100 Hz, squeezelite's built-in `pcm`
5252+codec copies it straight to the audio device.
5353+5454+---
5555+5656+## Architecture diagram
5757+5858+```
5959+┌─────────────────────────────────────────────────────────────────┐
6060+│ Rockbox firmware (C) │
6161+│ │
6262+│ codec thread ──► PCM buffer ──► pcm_play_dma_complete_callback │
6363+│ │ │
6464+│ squeezelite_thread (pthread) │
6565+│ │ │
6666+│ pcm_squeezelite_write() ◄── FFI │
6767+└─────────────────────────────────────────────────────────────────┘
6868+ │
6969+ ══════════════╪══════════ FFI boundary
7070+ │
7171+┌─────────────────────────────────────────────────────────────────┐
7272+│ rockbox-slim crate (Rust) │
7373+│ │
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) │
8585+└────────────────────────────┬────────────────────────────────────┘
8686+ │ TCP :9999
8787+ ┌──────────────┼──────────────┐
8888+ ▼ ▼ ▼
8989+ squeezelite squeezelite squeezelite
9090+ "Room 1" "Room 2" "Room 3"
9191+```
9292+9393+---
9494+9595+## Layer 1 — PCM sink abstraction (C)
9696+9797+**Files:** `firmware/export/pcm_sink.h`, `firmware/pcm.c`
9898+9999+Rockbox's hosted audio output is built around `struct pcm_sink`, a vtable of
100100+seven operations:
101101+102102+```c
103103+struct pcm_sink_ops {
104104+ void (*init)(void);
105105+ void (*postinit)(void);
106106+ void (*set_freq)(uint16_t freq); // called when sample rate changes
107107+ void (*lock)(void);
108108+ void (*unlock)(void);
109109+ void (*play)(const void *addr, size_t size); // sink_dma_start
110110+ void (*stop)(void); // sink_dma_stop
111111+};
112112+```
113113+114114+The squeezelite sink is registered as `PCM_SINK_SQUEEZELITE = 3`:
115115+116116+```c
117117+// firmware/export/pcm_sink.h
118118+enum pcm_sink_ids {
119119+ PCM_SINK_BUILTIN = 0, // SDL audio
120120+ PCM_SINK_FIFO = 1, // named FIFO → Snapcast
121121+ PCM_SINK_AIRPLAY = 2, // RAOP/RTP → AirPlay receiver
122122+ PCM_SINK_SQUEEZELITE = 3, // Slim Protocol + HTTP → squeezelite
123123+ PCM_SINK_NUM
124124+};
125125+```
126126+127127+`pcm_switch_sink(PCM_SINK_SQUEEZELITE)` (called from `crates/settings`) swaps
128128+the active vtable. All subsequent `pcm_play_data()` calls drive the
129129+squeezelite sink.
130130+131131+---
132132+133133+## Layer 2 — DMA thread and real-time pacing (C)
134134+135135+**File:** `firmware/target/hosted/pcm-squeezelite.c`
136136+137137+### sink_dma_start / sink_dma_stop
138138+139139+`sink_dma_start(addr, size)` is called by the Rockbox PCM engine whenever a
140140+new track (or track segment) begins. It:
141141+142142+1. Calls `pcm_squeezelite_start()` — idempotent; starts the Slim and HTTP
143143+ servers on first call only.
144144+2. Captures `play_start = CLOCK_MONOTONIC` and resets `play_bytes = 0` to
145145+ anchor real-time pacing.
146146+3. Stores the initial `(addr, size)` buffer pointer.
147147+4. Spawns `squeezelite_thread`.
148148+149149+`sink_dma_stop()` sets `squeezelite_stop = true`, joins the thread, and calls
150150+the no-op `pcm_squeezelite_stop()`.
151151+152152+### squeezelite_thread — the DMA loop
153153+154154+The thread mimics a hardware DMA interrupt loop in software:
155155+156156+```
157157+loop:
158158+ 1. Grab current (data, size) under mutex, clear pcm_data/pcm_size
159159+ 2. pcm_squeezelite_write(data, size) → push to BroadcastBuffer
160160+ 3. Real-time pace (sleep if ahead of wall clock)
161161+ 4. pcm_play_dma_complete_callback() → get next (pcm_data, pcm_size)
162162+ 5. pcm_play_dma_status_callback(STARTED)
163163+ 6. goto loop
164164+```
165165+166166+`pcm_play_dma_complete_callback()` is the hook Rockbox uses to advance the
167167+codec's read pointer — calling it at the wrong rate directly affects the
168168+playback-position counter shown in the UI.
169169+170170+### Real-time pacing
171171+172172+Without pacing, the loop drains an entire track in milliseconds (the ring
173173+buffer write is a memory copy). Pacing is implemented with a running byte
174174+counter:
175175+176176+```c
177177+play_bytes += size;
178178+uint64_t bps = (uint64_t)current_sample_rate * 4; // S16LE stereo
179179+uint64_t expected_us = play_bytes * 1000000ULL / bps;
180180+181181+struct timespec now;
182182+clock_gettime(CLOCK_MONOTONIC, &now);
183183+184184+// IMPORTANT: use int64_t, not uint64_t.
185185+// tv_nsec subtraction wraps to ~10^19 when tv_nsec rolls over,
186186+// making elapsed_us astronomically large and disabling all sleeps.
187187+int64_t elapsed_us =
188188+ (int64_t)(now.tv_sec - play_start.tv_sec) * 1000000LL +
189189+ ((int64_t)now.tv_nsec - (int64_t)play_start.tv_nsec) / 1000LL;
190190+191191+if (elapsed_us >= 0 && expected_us > (uint64_t)elapsed_us)
192192+ usleep((useconds_t)(expected_us - (uint64_t)elapsed_us));
193193+```
194194+195195+`play_start` and `play_bytes` are **reset on every `sink_dma_start()`** (i.e.
196196+every track), so drift does not accumulate across tracks.
197197+198198+---
199199+200200+## Layer 3 — FFI boundary
201201+202202+**Files:** `crates/sys/src/lib.rs`, `crates/sys/src/sound/pcm.rs`
203203+204204+The C symbols exported by `pcm-squeezelite.c` are declared as `extern "C"` in
205205+`crates/sys/src/lib.rs` and wrapped with safe Rust helpers in
206206+`crates/sys/src/sound/pcm.rs`:
207207+208208+| C symbol | Rust wrapper |
209209+|--------------------------------------|------------------------------------------|
210210+| `pcm_squeezelite_set_slim_port(u16)` | `pcm::squeezelite_set_slim_port(u16)` |
211211+| `pcm_squeezelite_set_http_port(u16)` | `pcm::squeezelite_set_http_port(u16)` |
212212+| `pcm_switch_sink(i32)` | `pcm::switch_sink(PCM_SINK_SQUEEZELITE)` |
213213+214214+These wrappers are called from `crates/settings/src/lib.rs:load_settings()`
215215+when `audio_output = "squeezelite"` is found in `settings.toml`.
216216+217217+### Force-link shim
218218+219219+`rockbox-slim` is an `rlib`, not a `staticlib`. The Rust linker would dead-
220220+strip it unless something directly references its symbols. A dummy function
221221+in `crates/cli/src/lib.rs` forces all FFI exports into `librockbox_cli.a`:
222222+223223+```rust
224224+// crates/cli/src/lib.rs
225225+#[allow(unused_imports)]
226226+use rockbox_slim::_link_slim as _;
227227+```
228228+229229+```rust
230230+// crates/slim/src/lib.rs
231231+#[doc(hidden)]
232232+pub fn _link_slim() {}
233233+```
234234+235235+---
236236+237237+## Layer 4 — Broadcast buffer (Rust)
238238+239239+**File:** `crates/slim/src/lib.rs`
240240+241241+### Design
242242+243243+The `BroadcastBuffer` stores PCM chunks as `(seq: u64, data: Vec<u8>)` tuples
244244+in a `VecDeque`. The writer holds a single monotonically-increasing
245245+`next_seq` cursor. Each reader (`BroadcastReceiver`) holds its own
246246+independent `next_seq` cursor.
247247+248248+```
249249+write cursor ──────────────────────────────────────────► next_seq
250250+ │
251251+chunks: [(100,…), (101,…), (102,…), (103,…), (104,…)] │
252252+ ▲ ▲ ▲ │
253253+ │ │ │ │
254254+ reader C reader A reader B │
255255+ (lagging) (live) (live) │
256256+```
257257+258258+### Push (writer)
259259+260260+```rust
261261+pub(crate) fn push(&self, data: &[u8]) {
262262+ // assign sequence number, append chunk
263263+ // evict oldest chunks while total_bytes > MAX_BUFFERED (4 MB)
264264+ // notify all waiting receivers
265265+}
266266+```
267267+268268+Eviction happens from the front; the `next_seq` counter is never reset, so
269269+existing receivers automatically detect that they have fallen behind.
270270+271271+### Subscribe (new reader)
272272+273273+```rust
274274+pub(crate) fn subscribe(self: &Arc<Self>) -> BroadcastReceiver {
275275+ // start at the current write cursor — live stream, no stale data
276276+}
277277+```
278278+279279+### recv_blocking (reader)
280280+281281+```rust
282282+pub(crate) fn recv_blocking(&mut self) -> RecvResult {
283283+ loop {
284284+ // 1. If receiver is behind front_seq → skip forward (log warning)
285285+ // 2. If chunk is available → return it, advance next_seq
286286+ // 3. If closed → return Closed
287287+ // 4. Otherwise → condvar.wait (release lock, sleep)
288288+ }
289289+}
290290+```
291291+292292+The index into the `VecDeque` is computed as
293293+`(self.next_seq - front_seq) as usize`, which is O(1).
294294+295295+### Capacity and eviction
296296+297297+The buffer is capped at **4 MB ≈ 23 seconds** of S16LE stereo at 44 100 Hz.
298298+Chunks are evicted oldest-first when the cap is exceeded. A lagging reader
299299+whose `next_seq` points to an evicted chunk silently skips forward to the
300300+oldest available chunk — it experiences a brief audio discontinuity but does
301301+not block the writer or other readers.
302302+303303+---
304304+305305+## Layer 5 — HTTP stream server (Rust)
306306+307307+**File:** `crates/slim/src/http.rs`
308308+309309+The HTTP server accepts connections on `0.0.0.0:9999` (configurable). Each
310310+accepted connection is handled in a **dedicated thread**:
311311+312312+```
313313+listener.incoming() loop
314314+ │
315315+ └── thread::spawn per connection
316316+ │
317317+ ├── drain_request() — read & discard HTTP request headers
318318+ ├── write HTTP 200 response headers
319319+ ├── buf.subscribe() — get a fresh BroadcastReceiver
320320+ └── loop { recv_blocking() → write_all() }
321321+```
322322+323323+### Response headers
324324+325325+```
326326+HTTP/1.0 200 OK
327327+Content-Type: audio/L16;rate=44100;channels=2
328328+Cache-Control: no-cache
329329+```
330330+331331+`audio/L16` is the MIME type for raw signed 16-bit PCM. squeezelite ignores
332332+the Content-Type and uses the codec declared in the `STRM` command (`p` =
333333+raw PCM), but the header documents the wire format.
334334+335335+### Back-pressure
336336+337337+squeezelite reads from the TCP socket at exactly the rate its audio device
338338+consumes PCM (176 400 bytes/s at 44 100 Hz stereo 16-bit). The OS TCP send
339339+buffer provides natural back-pressure: if squeezelite is slow, `write_all()`
340340+blocks, which causes `recv_blocking()` to hold the lock longer, which in turn
341341+causes the C DMA thread to block in `pcm_squeezelite_write()` — naturally
342342+slowing the DMA loop.
343343+344344+---
345345+346346+## Layer 6 — Slim Protocol server (Rust)
347347+348348+**File:** `crates/slim/src/slimproto.rs`
349349+350350+The Slim Protocol server listens on `0.0.0.0:3483` (configurable). Each
351351+squeezelite client gets its own thread.
352352+353353+### Packet framing
354354+355355+The Slim Protocol uses **asymmetric framing**:
356356+357357+| Direction | Wire format |
358358+|-----------------|-----------------------------------------------------|
359359+| Client → Server | `opcode[4]` + `u32 length BE` + `payload[length]` |
360360+| Server → Client | `u16 length BE` + `opcode[4]` + `payload[length-4]` |
361361+362362+The `u16 length` field in server→client packets counts the opcode (4 bytes)
363363+plus the payload, but **not the 2-byte length field itself**.
364364+365365+### Session flow
366366+367367+```
368368+squeezelite Slim server
369369+─────────── ───────────
370370+HELO ──────────────────────────────►
371371+ (no kick — all clients share the stream)
372372+ ◄────────────────────────── STRM 's'
373373+HTTP 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+ …
382382+```
383383+384384+### STRM 's' payload layout
385385+386386+The `STRM` packet instructs squeezelite where and how to fetch audio. All
387387+index fields are **ASCII-encoded**: squeezelite subtracts `'0'` before use.
388388+389389+```
390390+Offset Size Value Meaning
391391+────── ──── ────── ───────────────────────────────────────────────
392392+ 0 1 's' command: start
393393+ 1 1 '1' autostart: 1 = buffer then play immediately
394394+ 2 1 'p' format: raw PCM
395395+ 3 1 '1' pcm_sample_size: '1'-'0'+1 = 2 bytes = 16-bit
396396+ 4 1 '3' pcm_sample_rate: sample_rates[3] = 44 100 Hz
397397+ 5 1 '2' pcm_channels: '2'-'0' = 2 (stereo)
398398+ 6 1 '1' pcm_endianness: '1'≠'0' → little-endian
399399+ 7 1 255 threshold: 255 KB before auto-start
400400+ 8 1 0 spdif_enable
401401+ 9 1 0 transition_period
402402+10 1 '0' transition_type: none
403403+11 1 0 flags
404404+12 1 0 output_threshold
405405+13 1 0 slaves
406406+14 4 BE u32 replay_gain = 0x00010000 (1.0 in 16.16 fixed-point)
407407+18 2 BE u16 server_port (HTTP audio port)
408408+20 4 BE u32 server_ip = 0 → squeezelite uses the Slim server IP
409409+24 * bytes "GET /stream.pcm HTTP/1.0\r\n\r\n"
410410+```
411411+412412+`server_ip = 0` is a squeezelite convention: when the IP is zero, squeezelite
413413+uses the IP address it connected to for Slim Protocol. This means the HTTP
414414+server must be on the same host as the Slim server.
415415+416416+### audg keepalive
417417+418418+squeezelite counts consecutive 1-second `select()` timeouts on the Slim
419419+socket. After **36 consecutive timeouts** with no data from the server it
420420+logs `"No messages from server — connection dead"` and reconnects.
421421+422422+The fix: reply to every `STMt` (timer tick, sent by squeezelite once per
423423+second) with an `audg` (audio gain) packet:
424424+425425+```
426426+Offset Size Value Meaning
427427+────── ──── ────────── ───────────────────────────────────────
428428+ 0 4 0x00010000 left gain = 1.0 (16.16 BE fixed-point)
429429+ 4 4 0x00010000 right gain = 1.0
430430+ 8 1 0 do not adjust by replay gain
431431+```
432432+433433+This resets squeezelite's timeout counter to zero on every tick.
434434+435435+---
436436+437437+## Layer 7 — squeezelite client
438438+439439+squeezelite handles the audio pipeline in three internal threads:
440440+441441+| Thread | Role |
442442+|--------------------------------------|---------------------------------------------------------------|
443443+| `stream_thread` | Reads HTTP stream into `streambuf` (2 MB ring buffer) |
444444+| `decode_thread` | PCM "decoder": memcpy from `streambuf` → `outputbuf` (3.5 MB) |
445445+| `output_thread` / PortAudio callback | Reads `outputbuf`, sends to audio device |
446446+447447+For raw PCM (`format = 'p'`), the decode step is a straight memory copy with
448448+no transcoding, so latency between push and playback is determined solely by
449449+squeezelite's buffer thresholds (default ~255 KB ≈ 1.4 seconds at 44 100 Hz).
450450+451451+---
452452+453453+## Startup sequence
454454+455455+```
456456+rockboxd starts
457457+ │
458458+ └── load_settings() reads audio_output = "squeezelite"
459459+ │
460460+ ├── pcm::squeezelite_set_slim_port(3483)
461461+ ├── pcm::squeezelite_set_http_port(9999)
462462+ └── pcm::switch_sink(PCM_SINK_SQUEEZELITE)
463463+ │
464464+ └── swaps active pcm_sink vtable
465465+466466+user plays a track
467467+ │
468468+ └── sink_dma_start(addr, size) [pcm-squeezelite.c]
469469+ │
470470+ ├── pcm_squeezelite_start() [lib.rs — idempotent]
471471+ │ ├── spawn HTTP server thread on :9999
472472+ │ └── spawn Slim server thread on :3483
473473+ │
474474+ ├── reset play_start, play_bytes
475475+ └── spawn squeezelite_thread
476476+477477+squeezelite -s localhost
478478+ │
479479+ ├── TCP connect :3483
480480+ ├── send HELO
481481+ ├── receive STRM 's' → TCP connect :9999
482482+ ├── receive HTTP 200
483483+ └── start buffering PCM
484484+```
485485+486486+---
487487+488488+## Track transition
489489+490490+Between tracks, rockboxd calls `sink_dma_stop()` then `sink_dma_start()`:
491491+492492+1. `sink_dma_stop()`: sets `squeezelite_stop = true`, joins the DMA thread,
493493+ clears `pcm_data`/`pcm_size`. The broadcast buffer is **not flushed** —
494494+ squeezelite's in-flight HTTP connection continues reading.
495495+496496+2. `sink_dma_start()`: `pcm_squeezelite_start()` is a no-op (already started).
497497+ Resets `play_start`/`play_bytes`, spawns a new DMA thread.
498498+499499+The gap between stop and start is a few milliseconds. squeezelite's
500500+`outputbuf` (≈ 20 s capacity) absorbs the gap without a dropout.
501501+502502+---
503503+504504+## Multi-room
505505+506506+Any number of squeezelite instances can connect simultaneously:
507507+508508+- Each opens a Slim connection (`:3483`) and receives an identical `STRM 's'`
509509+ command pointing at `:9999`.
510510+- Each opens an HTTP connection (`:9999`) and gets an independent
511511+ `BroadcastReceiver` starting at the current write cursor.
512512+- Chunks flow from the single writer to all readers independently.
513513+- A slow reader skips forward to the oldest available chunk; other readers
514514+ are unaffected.
515515+516516+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.
519519+520520+---
521521+522522+## Configuration
523523+524524+`~/.config/rockbox.org/settings.toml`:
525525+526526+```toml
527527+audio_output = "squeezelite"
528528+squeezelite_port = 3483 # Slim Protocol TCP port (default)
529529+squeezelite_http_port = 9999 # HTTP PCM stream port (default)
530530+```
531531+532532+To select a specific audio device in squeezelite:
533533+534534+```sh
535535+squeezelite -s localhost -l # list available devices
536536+squeezelite -s localhost -o "" # system default
537537+squeezelite -s localhost -o "Built-in Output"
538538+```
539539+540540+Debug logging:
541541+542542+```sh
543543+RUST_LOG=debug ./zig/zig-out/bin/rockboxd # rockboxd side
544544+squeezelite -s localhost -d all=debug # squeezelite side
545545+```
546546+547547+---
548548+549549+## Gotchas and non-obvious invariants
550550+551551+### 1. `int64_t` for the pacing diff — never `uint64_t`
552552+553553+`struct timespec::tv_nsec` is `long`. `(uint64_t)(now.tv_nsec - prev.tv_nsec)`
554554+wraps to ≈ 1.8 × 10¹⁹ when `tv_nsec` rolls over (once per second). This
555555+makes `elapsed_us` enormous, disables all `usleep()` calls for the rest of the
556556+track, and causes the DMA loop to drain PCM at CPU speed — advancing the
557557+playback position many times faster than real time. Always use `int64_t` for
558558+the subtraction.
559559+560560+### 2. squeezelite's 36-second watchdog
561561+562562+squeezelite counts consecutive 1-second `select()` timeouts. Without an
563563+`audg` keepalive, it declares the connection dead after 36 seconds and
564564+reconnects. The reconnect is seamless but causes a brief gap in the HTTP
565565+stream.
566566+567567+### 3. ASCII-encoded STRM fields
568568+569569+`pcm_sample_size`, `pcm_sample_rate`, `pcm_channels`, and `pcm_endianness` in
570570+the `STRM` payload are ASCII characters, not binary integers. squeezelite
571571+subtracts `'0'` (0x30) before use. Sending `0x03` instead of `'3'` (0x33)
572572+for the sample rate would silently select the wrong rate.
573573+574574+### 4. `server_ip = 0` in STRM
575575+576576+squeezelite interprets `server_ip = 0` as "use the IP I connected to for Slim
577577+Protocol". The HTTP server must therefore be reachable on the same host as
578578+the Slim server. A non-zero `server_ip` would make squeezelite connect to
579579+that explicit IP instead.
580580+581581+### 5. Force-link shim
582582+583583+`rockbox-slim` is an `rlib`. Without `use rockbox_slim::_link_slim as _` in
584584+`crates/cli/src/lib.rs`, the linker discards all its symbols and the
585585+`extern "C"` declarations in `pcm-squeezelite.c` produce undefined-reference
586586+errors at link time.
587587+588588+### 6. `next_seq` is never reset
589589+590590+`BroadcastBuffer::reset()` clears the chunk queue but does **not** reset
591591+`next_seq`. This ensures that receivers created before the reset
592592+automatically skip forward to newly pushed chunks rather than getting stuck
593593+waiting for sequence numbers that will never appear.