Rockbox open source high quality audio player as a Music Player Daemon
mpris rockbox mpd libadwaita audio rust zig deno
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #154 from tsirysndr/feat/chromecast-sink

Enforce Content-Length for Chromecast WAV and add documentation

authored by

Tsiry Sandratraina and committed by
GitHub
98c05cb7 3a274fbf

+363 -7
+1
README.md
··· 182 182 | MPD server | 6600 | MPD protocol | 183 183 | Slim Protocol (squeezelite) | 3483 | TCP | 184 184 | HTTP PCM stream (squeezelite) | 9999 | HTTP | 185 + | Chromecast WAV stream | 7881 | HTTP | 185 186 | UPnP Media Server (ContentDirectory) | 7878 | HTTP / SSDP | 186 187 | UPnP WAV broadcast (PCM sink) | 7879 | HTTP | 187 188 | UPnP MediaRenderer (AVTransport) | 7880 | HTTP / SSDP |
+327
crates/chromecast/README.md
··· 1 + # rockbox-chromecast 2 + 3 + Chromecast output sink for Rockbox Zig. Streams live audio from the Rockbox 4 + firmware to any Google Cast-compatible device on the LAN — Google Home, Chromecast 5 + Audio, Chromecast with Google TV, Nest Hub, and third-party Cast receivers. 6 + 7 + --- 8 + 9 + ## Architecture overview 10 + 11 + The Chromecast implementation has two independent, complementary halves: 12 + 13 + ``` 14 + ┌──────────────────────────────────────────────────────────────────────┐ 15 + │ rockboxd process │ 16 + │ │ 17 + │ Rockbox firmware (C) │ 18 + │ ┌────────────────────┐ │ 19 + │ │ PCM engine │──pcm_chromecast_write()──► BroadcastBuffer │ 20 + │ │ (44.1 kHz S16LE) │ (ring buffer) │ 21 + │ └────────────────────┘ │ │ 22 + │ │ │ 23 + │ ┌─────────────────────────────────────────────┐ │ │ 24 + │ │ HTTP server (port 7881) │◄──────────┘ │ 25 + │ │ GET /stream.wav → WAV header + PCM chunks│ │ 26 + │ │ GET /now-playing/art → album art (JPEG…) │ │ 27 + │ └─────────────────────┬───────────────────────┘ │ 28 + │ │ HTTP (WAV) │ 29 + │ ┌──────────────────────▼──────────────────────┐ │ 30 + │ │ Cast protocol thread │ │ 31 + │ │ (Cast TCP 8009, TLS, Protobuf) │ │ 32 + │ │ · media.load(url=http://host:7881/…) │ │ 33 + │ │ · heartbeat.ping() every 500 ms │ │ 34 + │ │ · on track change → media.load() again │ │ 35 + │ └──────────────────────┬──────────────────────┘ │ 36 + └─────────────────────────┼────────────────────────────────────────────┘ 37 + │ Cast protocol (TLS, port 8009) 38 + │ + WAV stream (HTTP, port 7881) 39 + ┌─────▼──────┐ 40 + │ Chromecast │ 41 + │ device │ 42 + └────────────┘ 43 + ``` 44 + 45 + The two halves communicate through a standard URL: the Cast protocol thread 46 + tells the device to fetch `http://<rockboxd-host>:7881/stream.wav`, and the HTTP 47 + server serves that stream from the broadcast buffer filled by the firmware. 48 + 49 + --- 50 + 51 + ## Module map 52 + 53 + | File | Responsibility | 54 + |---------------|------------------------------------------------------------------------------------| 55 + | `src/lib.rs` | `Player` trait implementation; Cast protocol control thread; MPSC command dispatch | 56 + | `src/pcm.rs` | HTTP WAV broadcast server; album art server; `BroadcastBuffer`; C FFI surface | 57 + | `src/main.rs` | Example binary (connects to a hardcoded IP for manual testing) | 58 + 59 + --- 60 + 61 + ## Part 1 — Cast protocol (src/lib.rs) 62 + 63 + ### Connection 64 + 65 + `Chromecast::connect(device: Device)` establishes the Cast session: 66 + 67 + 1. Open a TLS TCP connection to the device on port 8009 68 + (`CastDevice::connect_without_host_verification`) — host verification is 69 + skipped because Chromecast devices use self-signed certificates. 70 + 2. Connect to the built-in `receiver-0` destination. 71 + 3. Ping the heartbeat channel. 72 + 4. Launch the **Default Media Receiver** (app ID `"88DCBD57"`). 73 + 5. Save the resulting `transport_id` and `session_id`. 74 + 6. Spawn a background Tokio task (`CastPlayerInternal`) that owns the 75 + `CastDevice` handle and processes commands. 76 + 77 + The method returns a `Box<dyn Player>` usable through the shared `Player` trait. 78 + 79 + ### Command dispatch 80 + 81 + All player operations (play, pause, next, previous, load_tracks, …) send a 82 + `CastPlayerCommand` over an unbounded MPSC channel to the background task. 83 + This avoids blocking the calling thread and keeps the Cast device handle 84 + single-owner. 85 + 86 + ``` 87 + caller thread background task (CastPlayerInternal) 88 + │ │ 89 + │──CastPlayerCommand::Play─────────►│ 90 + │ │── cast_device.media.play() 91 + │──CastPlayerCommand::Next─────────►│ 92 + │ │── cast_device.media.next() 93 + │──CastPlayerCommand::LoadTracks──►│ 94 + │ │── cast_device.media.queue_load([…]) 95 + ``` 96 + 97 + ### Status polling 98 + 99 + Every 500 ms the background task calls `media.get_status()` and updates a 100 + shared `Arc<Mutex<CurrentPlayback>>`. `get_current_playback()` reads this value 101 + without talking to the device, so it is always fast and non-blocking. 102 + 103 + ### Track metadata 104 + 105 + When loading a track or a queue, Rockbox builds a Cast `Media` object with: 106 + 107 + - `content_id` — the stream URL (`http://<host>:7881/stream.wav`) 108 + - `content_type` — `"audio/wav"` 109 + - `stream_type` — `Buffered` when duration is known, `Live` otherwise 110 + - `duration` — seconds from the Rockbox track entry 111 + - `metadata` — `MusicTrackMediaMetadata` with title, artist, album, and an 112 + album art URL (`http://<host>:7881/now-playing/art?t=<seq>`) 113 + 114 + The `?t=<seq>` cache-buster increments on every track change so the Chromecast 115 + re-fetches the art rather than using its internal cache. 116 + 117 + ### Queue management 118 + 119 + - `load_tracks(tracks, start_index)` → `media.queue_load([Media…])` — replaces 120 + the entire queue and starts playback from `start_index`. 121 + - `play_next(track)` → `media.queue_insert(Media)` — inserts one track before 122 + the next queue item. 123 + 124 + ### Session lifecycle 125 + 126 + | Player method | Cast action | 127 + |----------------|---------------------------------------------------------| 128 + | `play()` | `media.play()` | 129 + | `pause()` | `media.pause()` | 130 + | `resume()` | `media.play()` | 131 + | `stop()` | no-op — session stays alive so pause/resume is seamless | 132 + | `next()` | `media.next()` | 133 + | `previous()` | `media.previous()` | 134 + | `disconnect()` | `receiver.stop_app(session_id)` | 135 + 136 + `stop()` is intentionally a no-op at the Cast level. The Cast app is only 137 + stopped when `disconnect()` is called explicitly (e.g. the user switches to 138 + another output or closes the app). This prevents the Chromecast UI from 139 + dismissing the "Now Playing" card between tracks. 140 + 141 + --- 142 + 143 + ## Part 2 — WAV/HTTP broadcast (src/pcm.rs) 144 + 145 + ### BroadcastBuffer 146 + 147 + ``` 148 + firmware (writer) 149 + │ pcm_chromecast_write(buf, len) 150 + 151 + ┌────────────────────────────────────────────┐ 152 + │ BroadcastBuffer (max 4 MB) │ 153 + │ - sequence counter (u64) │ 154 + │ - Vec of (seq, chunk) pairs │ 155 + │ - Condvar to wake sleeping readers │ 156 + │ - evict oldest chunks when full │ 157 + └────────────────────────────────────────────┘ 158 + │ reader cursor per HTTP client 159 + 160 + HTTP client 1, HTTP client 2, … 161 + ``` 162 + 163 + Each HTTP client (i.e. the Chromecast device) gets its own cursor into the 164 + buffer. A slow client skips forward to the current position rather than 165 + blocking the writer — this prevents audio glitches when network conditions are 166 + poor. 167 + 168 + ### WAV HTTP server 169 + 170 + The server listens on `chromecast_http_port` (default **7881**) and handles two 171 + routes: 172 + 173 + #### `GET /stream.wav` (or any path) 174 + 175 + 1. Computes a WAV RIFF header from the current track's sample rate, channel 176 + count, and duration: 177 + - `data_size = (duration_ms × byte_rate) / 1000` 178 + - `byte_rate = sample_rate × 4` (stereo 16-bit) 179 + - `Content-Length = 44 (header) + data_size` 180 + - If duration is unknown, `data_size = 0xFFFFFFFF` (Chromecast shows ∞). 181 + 2. Streams the header, then sends chunks from the broadcast buffer as they 182 + arrive until `data_size` bytes have been sent or the client disconnects. 183 + 184 + Using a finite `Content-Length` is important: Chromecast's Default Media 185 + Receiver uses it to show a progress bar and to know when a track ends so it 186 + can automatically advance to the next queue item. 187 + 188 + #### `GET /now-playing/art` 189 + 190 + Searches the directory of the currently playing track for common album art 191 + filenames: 192 + 193 + ``` 194 + cover.jpg cover.png cover.webp folder.jpg folder.png 195 + front.jpg front.png artwork.jpg artwork.jpeg artwork.png 196 + ``` 197 + 198 + Returns the first match with the correct MIME type, or 404 if none is found. 199 + 200 + ### Track change detection 201 + 202 + A dedicated `cast_loop` thread polls `rockbox_sys::playback::current_track()` 203 + every 500 ms and compares the path to the previously known path. On a change: 204 + 205 + 1. Increments the global `art_seq` counter. 206 + 2. Calls `media.load(Media{…})` on the active Cast session with fresh 207 + metadata and a cache-busted art URL. 208 + 3. The Chromecast fetches the new `/stream.wav` URL and the updated album art. 209 + 210 + ### FFI surface 211 + 212 + The C firmware calls these symbols (defined with `#[no_mangle]` in `pcm.rs`): 213 + 214 + ```c 215 + void pcm_chromecast_set_http_port(uint16_t port); 216 + void pcm_chromecast_set_device_host(const char *host); 217 + void pcm_chromecast_set_device_port(uint16_t port); 218 + void pcm_chromecast_set_sample_rate(uint32_t rate); 219 + int pcm_chromecast_start(void); /* idempotent; starts HTTP + cast threads */ 220 + int pcm_chromecast_write(const uint8_t *buf, size_t len); 221 + void pcm_chromecast_stop(void); /* no-op — session stays open */ 222 + void pcm_chromecast_close(void); /* stops threads, frees resources */ 223 + ``` 224 + 225 + The C implementation lives in 226 + `firmware/target/hosted/pcm-chromecast.c`, which is registered as 227 + `PCM_SINK_CHROMECAST` in `firmware/export/pcm_sink.h`. 228 + 229 + --- 230 + 231 + ## Device discovery 232 + 233 + Chromecast devices are discovered automatically via **mDNS** (`_googlecast._tcp.local.`). 234 + The `rockbox-discovery` crate (using `mdns_sd`) browses the LAN and publishes 235 + each found device as a `Device` struct: 236 + 237 + | Field | Value | 238 + |------------------|---------------------------------------| 239 + | `app` | `"chromecast"` | 240 + | `port` | `8009` (Cast protocol) | 241 + | `ip` | IPv4 address of the device | 242 + | `name` | Friendly name from mDNS `fn` property | 243 + | `is_cast_device` | `true` | 244 + 245 + Discovered devices appear in the GraphQL `devices` query and in the web/desktop 246 + UI device picker in real time. 247 + 248 + --- 249 + 250 + ## Connecting a device 251 + 252 + Once discovered, connect via: 253 + 254 + ``` 255 + POST /devices/:id/connect 256 + ``` 257 + 258 + This calls `Chromecast::connect(device)`, stores the resulting `Player` in the 259 + shared HTTP server context, and marks the device as active. All subsequent 260 + play/pause/next/… calls go through this player. 261 + 262 + Disconnect with: 263 + 264 + ``` 265 + POST /devices/:id/disconnect 266 + ``` 267 + 268 + This stops the Cast app session and clears the player context. 269 + 270 + --- 271 + 272 + ## Configuration 273 + 274 + Add to `~/.config/rockbox.org/settings.toml`: 275 + 276 + ```toml 277 + music_dir = "/path/to/Music" 278 + audio_output = "chromecast" 279 + 280 + chromecast_host = "192.168.1.60" # IP of the target Chromecast 281 + chromecast_port = 8009 # optional, default 8009 282 + chromecast_http_port = 7881 # optional, default 7881 283 + ``` 284 + 285 + `chromecast_host` must be the LAN IP of the Chromecast device you want to use 286 + as the fixed PCM output sink. If you prefer to select the device dynamically 287 + through the UI, use `audio_output = "builtin"` and connect via the device 288 + picker instead — the WAV stream and Cast session are started on demand. 289 + 290 + ### Port summary 291 + 292 + | Port | Protocol | Purpose | 293 + |------|-----------|-----------------------------------------------------| 294 + | 8009 | TCP / TLS | Cast control channel (Protobuf) | 295 + | 7881 | HTTP | WAV audio stream + album art served **by rockboxd** | 296 + 297 + > Port 7881 must be reachable from the Chromecast device. If rockboxd runs 298 + > inside a VM or container, ensure the WAV HTTP port is forwarded to the host. 299 + 300 + --- 301 + 302 + ## Known limitations 303 + 304 + | Feature | Status | 305 + |---------------------------------------|---------------------------------------------| 306 + | Play / pause / stop / next / previous | ✅ Implemented | 307 + | Queue management (load, insert) | ✅ Implemented | 308 + | Metadata + album art display | ✅ Implemented | 309 + | Volume control | ⏳ Not yet implemented | 310 + | Seek within track | ⏳ Not yet implemented | 311 + | Multi-device fan-out | ⏳ Not yet implemented (single device only) | 312 + 313 + --- 314 + 315 + ## Dependencies 316 + 317 + | Crate | Version | Purpose | 318 + |-------------------|-----------|-----------------------------------------------------------| 319 + | `chromecast` | 0.18.2 | Cast protocol client (Protobuf/TLS) | 320 + | `tokio` | workspace | Async runtime for Cast background task | 321 + | `async-trait` | workspace | `Player` trait with async methods | 322 + | `rockbox-traits` | local | `Player` trait definition | 323 + | `rockbox-types` | local | `Device`, `Track`, `Playback` types | 324 + | `rockbox-sys` | local | FFI to Rockbox C firmware (current track, playback state) | 325 + | `rockbox-library` | local | SQLite library for track lookups | 326 + | `md5` | — | Device ID hashing | 327 + | `tracing` | workspace | Structured logging |
+35 -7
crates/chromecast/src/pcm.rs
··· 344 344 buf: Arc<BroadcastBuffer>, 345 345 peer: &str, 346 346 ) { 347 - let hdr = "HTTP/1.0 200 OK\r\nContent-Type: audio/wav\r\nCache-Control: no-cache\r\n\r\n"; 348 - 349 - // Compute the exact PCM byte count from the track length so the Chromecast 350 - // can display an accurate progress bar. Falls back to 0xFFFFFFFF only when 351 - // no track info is available (pre-playback probe connections, etc.). 347 + // Compute the exact PCM byte count from the track length. 348 + // Without Content-Length, Chrome/Chromecast treats any HTTP response as a 349 + // live stream and shows ∞ — it does NOT read duration from the WAV header 350 + // alone. We must set Content-Length AND enforce the byte limit so the 351 + // response is a properly-bounded file the receiver can fully parse. 352 352 let duration_ms = rockbox_sys::playback::current_track() 353 353 .map(|t| t.length) 354 354 .filter(|&l| l > 0) ··· 361 361 }; 362 362 363 363 let wav_hdr = wav_header(sample_rate, data_size); 364 + let known_length = data_size != 0xFFFF_FFFF; 365 + 366 + let hdr = if known_length { 367 + format!( 368 + "HTTP/1.0 200 OK\r\nContent-Type: audio/wav\r\nContent-Length: {}\r\nCache-Control: no-cache\r\n\r\n", 369 + 44u64 + data_size as u64 370 + ) 371 + } else { 372 + "HTTP/1.0 200 OK\r\nContent-Type: audio/wav\r\nCache-Control: no-cache\r\n\r\n".to_string() 373 + }; 374 + 364 375 if stream.write_all(hdr.as_bytes()).is_err() || stream.write_all(&wav_hdr).is_err() { 365 376 return; 366 377 } 367 - tracing::info!("chromecast/pcm: streaming WAV to {peer}"); 378 + tracing::info!( 379 + "chromecast/pcm: streaming WAV to {peer} ({} bytes)", 380 + if known_length { data_size as u64 } else { 0 } 381 + ); 368 382 369 383 let mut rx = buf.subscribe(); 384 + let mut written: u64 = 0; 385 + let limit: u64 = if known_length { 386 + data_size as u64 387 + } else { 388 + u64::MAX 389 + }; 390 + 370 391 loop { 392 + if written >= limit { 393 + break; 394 + } 371 395 match rx.recv_blocking() { 372 396 RecvResult::Data(chunk) => { 373 - if stream.write_all(&chunk).is_err() { 397 + let remaining = (limit - written) as usize; 398 + let to_write = chunk.len().min(remaining); 399 + if stream.write_all(&chunk[..to_write]).is_err() { 374 400 tracing::debug!("chromecast/pcm: {peer} disconnected"); 375 401 break; 376 402 } 403 + written += to_write as u64; 377 404 } 378 405 RecvResult::Closed => break, 379 406 } 380 407 } 408 + tracing::debug!("chromecast/pcm: {peer} done ({written} bytes)"); 381 409 } 382 410 383 411 // ---------------------------------------------------------------------------