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 #143 from tsirysndr/feat/pcm-squeezelite

Add Squeezelite PCM sink with Slim protocol support

authored by

Tsiry Sandratraina and committed by
GitHub
65d94ef0 360d8163

+727 -8
+9
Cargo.lock
··· 9163 9163 "rockbox-library", 9164 9164 "rockbox-rocksky", 9165 9165 "rockbox-settings", 9166 + "rockbox-slim", 9166 9167 "rockbox-typesense", 9167 9168 "tokio", 9168 9169 "tracing", ··· 9405 9406 "anyhow", 9406 9407 "rockbox-sys", 9407 9408 "toml 0.8.19", 9409 + "tracing", 9410 + ] 9411 + 9412 + [[package]] 9413 + name = "rockbox-slim" 9414 + version = "0.1.0" 9415 + dependencies = [ 9416 + "tracing", 9408 9417 ] 9409 9418 9410 9419 [[package]]
+1
README.md
··· 65 65 - [ ] UPnP/DLNA 66 66 - [x] Airplay 67 67 - [x] Snapcast 68 + - [x] Slim Protocol 68 69 - [ ] TypeScript ([Deno](https://deno.com)) API (for writing plugins) 69 70 - [ ] Wasm extensions 70 71
+1
crates/cli/Cargo.toml
··· 9 9 [dependencies] 10 10 anyhow = "1.0.90" 11 11 rockbox-airplay = {path = "../airplay"} 12 + rockbox-slim = {path = "../slim"} 12 13 clap = "4.5.16" 13 14 owo-colors = "4.1.0" 14 15 rockbox-library = {path = "../library"}
+3 -1
crates/cli/src/lib.rs
··· 1 1 use anyhow::Error; 2 2 3 - // Force rockbox-airplay symbols into librockbox_cli.a 3 + // Force rockbox-airplay and rockbox-slim symbols into librockbox_cli.a 4 4 use clap::Command; 5 5 use owo_colors::OwoColorize; 6 6 #[allow(unused_imports)] 7 7 use rockbox_airplay::_link_airplay as _; 8 8 use rockbox_library::audio_scan::{save_audio_metadata, scan_audio_files}; 9 9 use rockbox_library::{create_connection_pool, repo}; 10 + #[allow(unused_imports)] 11 + use rockbox_slim::_link_slim as _; 10 12 use rockbox_typesense::client::*; 11 13 use rockbox_typesense::types::*; 12 14 use std::io::{BufRead, BufReader};
+2
crates/rpc/src/lib.rs
··· 933 933 fifo_path: None, 934 934 airplay_host: None, 935 935 airplay_port: None, 936 + squeezelite_http_port: None, 937 + squeezelite_port: None, 936 938 } 937 939 } 938 940 }
+1
crates/settings/Cargo.toml
··· 7 7 anyhow = "1.0.91" 8 8 rockbox-sys = {path = "../sys"} 9 9 toml = "0.8.19" 10 + tracing = { workspace = true }
+27 -3
crates/settings/src/lib.rs
··· 28 28 29 29 rb::settings::save_settings(settings.clone(), new_settings.is_none()); 30 30 31 - if let Some(ref output) = settings.audio_output { 32 - if output == "fifo" { 31 + match settings.audio_output.as_deref() { 32 + Some("fifo") => { 33 33 let path = settings.fifo_path.as_deref().unwrap_or("/tmp/rockbox.fifo"); 34 34 pcm::fifo_set_path(path); 35 35 pcm::switch_sink(pcm::PCM_SINK_FIFO); 36 - } else if output == "airplay" { 36 + tracing::info!("audio output: fifo ({})", path); 37 + } 38 + Some("airplay") => { 37 39 if let Some(ref host) = settings.airplay_host { 38 40 let port = settings.airplay_port.unwrap_or(5000); 39 41 pcm::airplay_set_host(host, port); 40 42 pcm::switch_sink(pcm::PCM_SINK_AIRPLAY); 43 + tracing::info!("audio output: airplay ({}:{})", host, port); 44 + } else { 45 + tracing::warn!("audio output: airplay selected but airplay_host is not set"); 41 46 } 47 + } 48 + Some("squeezelite") => { 49 + let slim_port = settings.squeezelite_port.unwrap_or(3483); 50 + let http_port = settings.squeezelite_http_port.unwrap_or(9999); 51 + pcm::squeezelite_set_slim_port(slim_port); 52 + pcm::squeezelite_set_http_port(http_port); 53 + pcm::switch_sink(pcm::PCM_SINK_SQUEEZELITE); 54 + tracing::info!( 55 + "audio output: squeezelite (Slim Protocol :{slim_port}, HTTP audio :{http_port})" 56 + ); 57 + } 58 + Some("builtin") | None => { 59 + tracing::info!("audio output: builtin (SDL)"); 60 + } 61 + Some(other) => { 62 + tracing::warn!( 63 + "audio output: unknown value {:?}, falling back to builtin", 64 + other 65 + ); 42 66 } 43 67 } 44 68
+10
crates/slim/Cargo.toml
··· 1 + [package] 2 + name = "rockbox-slim" 3 + version = "0.1.0" 4 + edition = "2021" 5 + 6 + [lib] 7 + crate-type = ["rlib"] 8 + 9 + [dependencies] 10 + tracing = { workspace = true }
+78
crates/slim/src/http.rs
··· 1 + use crate::{BroadcastBuffer, RecvResult}; 2 + use std::io::Write; 3 + use std::net::TcpListener; 4 + use std::sync::Arc; 5 + 6 + /// HTTP audio stream server. Each accepted connection is handled in its own 7 + /// thread and receives an independent BroadcastReceiver cursor into the shared 8 + /// PCM buffer, so any number of squeezelite clients can play simultaneously. 9 + pub fn serve(port: u16, buf: Arc<BroadcastBuffer>) { 10 + let listener = match TcpListener::bind(("0.0.0.0", port)) { 11 + Ok(l) => l, 12 + Err(e) => { 13 + tracing::error!("slim/http: bind :{port} failed: {e}"); 14 + return; 15 + } 16 + }; 17 + tracing::info!("slim/http: listening on :{port}"); 18 + 19 + for stream in listener.incoming() { 20 + match stream { 21 + Ok(mut stream) => { 22 + let buf = buf.clone(); 23 + std::thread::spawn(move || { 24 + let peer = stream 25 + .peer_addr() 26 + .map(|a| a.to_string()) 27 + .unwrap_or_default(); 28 + 29 + if let Err(e) = drain_request(&mut stream) { 30 + tracing::warn!("slim/http: request read error from {peer}: {e}"); 31 + return; 32 + } 33 + 34 + let headers = b"HTTP/1.0 200 OK\r\n\ 35 + Content-Type: audio/L16;rate=44100;channels=2\r\n\ 36 + Cache-Control: no-cache\r\n\ 37 + \r\n"; 38 + if let Err(e) = stream.write_all(headers) { 39 + tracing::warn!("slim/http: header write error to {peer}: {e}"); 40 + return; 41 + } 42 + 43 + tracing::info!("slim/http: streaming PCM to {peer}"); 44 + let mut rx = buf.subscribe(); 45 + 46 + loop { 47 + match rx.recv_blocking() { 48 + RecvResult::Data(chunk) => { 49 + if stream.write_all(&chunk).is_err() { 50 + tracing::debug!("slim/http: {peer} disconnected"); 51 + break; 52 + } 53 + } 54 + RecvResult::Closed => break, 55 + } 56 + } 57 + }); 58 + } 59 + Err(e) => tracing::warn!("slim/http: accept error: {e}"), 60 + } 61 + } 62 + } 63 + 64 + fn drain_request(stream: &mut std::net::TcpStream) -> std::io::Result<()> { 65 + use std::io::Read; 66 + let mut buf: Vec<u8> = Vec::with_capacity(512); 67 + let mut byte = [0u8; 1]; 68 + loop { 69 + stream.read_exact(&mut byte)?; 70 + buf.push(byte[0]); 71 + if buf.ends_with(b"\r\n\r\n") || buf.ends_with(b"\n\n") { 72 + return Ok(()); 73 + } 74 + if buf.len() > 8192 { 75 + return Ok(()); 76 + } 77 + } 78 + }
+215
crates/slim/src/lib.rs
··· 1 + mod http; 2 + mod slimproto; 3 + 4 + // Called from rockbox-cli to force this crate's symbols into librockbox_cli.a 5 + #[doc(hidden)] 6 + pub fn _link_slim() {} 7 + 8 + use std::collections::VecDeque; 9 + use std::sync::{Arc, Condvar, Mutex, OnceLock}; 10 + 11 + // --------------------------------------------------------------------------- 12 + // Broadcast buffer — one writer, N independent readers. 13 + // 14 + // Each chunk is stored with a monotonically-increasing sequence number. 15 + // Every reader (one per squeezelite HTTP connection) keeps its own 16 + // `next_seq` cursor and reads chunks independently. Old chunks are evicted 17 + // once the buffer exceeds MAX_BUFFERED bytes; a lagging reader skips forward 18 + // to the oldest available chunk rather than blocking the writer. 19 + // --------------------------------------------------------------------------- 20 + 21 + pub(crate) enum RecvResult { 22 + Data(Vec<u8>), 23 + Closed, 24 + } 25 + 26 + pub(crate) struct BroadcastBuffer { 27 + inner: Mutex<BroadcastInner>, 28 + condvar: Condvar, 29 + } 30 + 31 + struct BroadcastInner { 32 + chunks: VecDeque<(u64, Vec<u8>)>, // (seq, payload) 33 + next_seq: u64, 34 + total_bytes: usize, 35 + closed: bool, 36 + } 37 + 38 + // 4 MB — about 23 s of S16LE stereo at 44100 Hz 39 + const MAX_BUFFERED: usize = 4 * 1024 * 1024; 40 + 41 + impl BroadcastBuffer { 42 + fn new() -> Self { 43 + BroadcastBuffer { 44 + inner: Mutex::new(BroadcastInner { 45 + chunks: VecDeque::new(), 46 + next_seq: 0, 47 + total_bytes: 0, 48 + closed: false, 49 + }), 50 + condvar: Condvar::new(), 51 + } 52 + } 53 + 54 + pub(crate) fn push(&self, data: &[u8]) { 55 + let mut g = self.inner.lock().unwrap(); 56 + if g.closed { 57 + return; 58 + } 59 + let seq = g.next_seq; 60 + g.next_seq += 1; 61 + g.total_bytes += data.len(); 62 + g.chunks.push_back((seq, data.to_vec())); 63 + while g.total_bytes > MAX_BUFFERED { 64 + if let Some((_, old)) = g.chunks.pop_front() { 65 + g.total_bytes -= old.len(); 66 + } else { 67 + break; 68 + } 69 + } 70 + self.condvar.notify_all(); 71 + } 72 + 73 + /// Subscribe from the current write position (live stream, no old data). 74 + pub(crate) fn subscribe(self: &Arc<Self>) -> BroadcastReceiver { 75 + let next_seq = self.inner.lock().unwrap().next_seq; 76 + BroadcastReceiver { 77 + buf: Arc::clone(self), 78 + next_seq, 79 + } 80 + } 81 + 82 + fn reset(&self) { 83 + let mut g = self.inner.lock().unwrap(); 84 + g.chunks.clear(); 85 + g.total_bytes = 0; 86 + g.closed = false; 87 + // next_seq is NOT reset — existing receivers skip forward automatically. 88 + } 89 + 90 + fn close(&self) { 91 + let mut g = self.inner.lock().unwrap(); 92 + g.closed = true; 93 + self.condvar.notify_all(); 94 + } 95 + } 96 + 97 + pub(crate) struct BroadcastReceiver { 98 + buf: Arc<BroadcastBuffer>, 99 + next_seq: u64, 100 + } 101 + 102 + impl BroadcastReceiver { 103 + pub(crate) fn recv_blocking(&mut self) -> RecvResult { 104 + let mut g = self.buf.inner.lock().unwrap(); 105 + loop { 106 + if g.closed { 107 + return RecvResult::Closed; 108 + } 109 + if let Some(&(front_seq, _)) = g.chunks.front() { 110 + // Lagging reader: skip to oldest available chunk. 111 + if self.next_seq < front_seq { 112 + tracing::debug!( 113 + "slim/broadcast: receiver lagging, skipping {} → {}", 114 + self.next_seq, 115 + front_seq 116 + ); 117 + self.next_seq = front_seq; 118 + } 119 + // Data is available for this reader. 120 + if self.next_seq < g.next_seq { 121 + let idx = (self.next_seq - front_seq) as usize; 122 + let chunk = g.chunks[idx].1.clone(); 123 + self.next_seq += 1; 124 + return RecvResult::Data(chunk); 125 + } 126 + } 127 + g = self.buf.condvar.wait(g).unwrap(); 128 + } 129 + } 130 + } 131 + 132 + // --------------------------------------------------------------------------- 133 + // Global state 134 + // --------------------------------------------------------------------------- 135 + 136 + static BUFFER: OnceLock<Arc<BroadcastBuffer>> = OnceLock::new(); 137 + static STARTED: Mutex<bool> = Mutex::new(false); 138 + 139 + struct SlimConfig { 140 + slim_port: u16, 141 + http_port: u16, 142 + } 143 + 144 + static CONFIG: Mutex<SlimConfig> = Mutex::new(SlimConfig { 145 + slim_port: 3483, 146 + http_port: 9999, 147 + }); 148 + 149 + fn get_buffer() -> Arc<BroadcastBuffer> { 150 + BUFFER 151 + .get_or_init(|| Arc::new(BroadcastBuffer::new())) 152 + .clone() 153 + } 154 + 155 + // --------------------------------------------------------------------------- 156 + // FFI exports 157 + // --------------------------------------------------------------------------- 158 + 159 + #[no_mangle] 160 + pub extern "C" fn pcm_squeezelite_set_slim_port(port: u16) { 161 + CONFIG.lock().unwrap().slim_port = port; 162 + } 163 + 164 + #[no_mangle] 165 + pub extern "C" fn pcm_squeezelite_set_http_port(port: u16) { 166 + CONFIG.lock().unwrap().http_port = port; 167 + } 168 + 169 + /// Start Slim Protocol + HTTP servers. Idempotent. 170 + #[no_mangle] 171 + pub extern "C" fn pcm_squeezelite_start() -> std::os::raw::c_int { 172 + let mut started = STARTED.lock().unwrap(); 173 + if *started { 174 + return 0; 175 + } 176 + 177 + let cfg = CONFIG.lock().unwrap(); 178 + let slim_port = cfg.slim_port; 179 + let http_port = cfg.http_port; 180 + drop(cfg); 181 + 182 + let buf = get_buffer(); 183 + buf.reset(); 184 + 185 + let buf_http = buf.clone(); 186 + std::thread::spawn(move || http::serve(http_port, buf_http)); 187 + std::thread::spawn(move || slimproto::serve(slim_port, http_port)); 188 + 189 + *started = true; 190 + tracing::info!("squeezelite sink: Slim Protocol on :{slim_port}, HTTP audio on :{http_port}"); 191 + 0 192 + } 193 + 194 + /// Push raw S16LE stereo PCM into the broadcast buffer. 195 + #[no_mangle] 196 + pub extern "C" fn pcm_squeezelite_write(data: *const u8, len: usize) -> std::os::raw::c_int { 197 + if data.is_null() || len == 0 { 198 + return 0; 199 + } 200 + let slice = unsafe { std::slice::from_raw_parts(data, len) }; 201 + get_buffer().push(slice); 202 + 0 203 + } 204 + 205 + /// No-op between tracks — all squeezelite clients keep their HTTP connections. 206 + #[no_mangle] 207 + pub extern "C" fn pcm_squeezelite_stop() {} 208 + 209 + /// Shut down servers (called on daemon exit). 210 + #[no_mangle] 211 + pub extern "C" fn pcm_squeezelite_close() { 212 + let mut started = STARTED.lock().unwrap(); 213 + get_buffer().close(); 214 + *started = false; 215 + }
+140
crates/slim/src/slimproto.rs
··· 1 + use std::io::{Read, Write}; 2 + use std::net::{TcpListener, TcpStream}; 3 + 4 + /// Slim Protocol TCP server. Each squeezelite instance that connects gets a 5 + /// STRM command pointing at our HTTP broadcast endpoint. Multiple clients are 6 + /// fully supported — each connects to the same HTTP port and receives its own 7 + /// independent read cursor into the shared PCM broadcast buffer. 8 + pub fn serve(slim_port: u16, http_port: u16) { 9 + let listener = match TcpListener::bind(("0.0.0.0", slim_port)) { 10 + Ok(l) => l, 11 + Err(e) => { 12 + tracing::error!("slim: bind :{slim_port} failed: {e}"); 13 + return; 14 + } 15 + }; 16 + tracing::info!("slim: listening on :{slim_port}"); 17 + 18 + for stream in listener.incoming() { 19 + match stream { 20 + Ok(stream) => { 21 + std::thread::spawn(move || handle_client(stream, http_port)); 22 + } 23 + Err(e) => tracing::warn!("slim: accept error: {e}"), 24 + } 25 + } 26 + } 27 + 28 + fn handle_client(mut stream: TcpStream, http_port: u16) { 29 + let peer = stream 30 + .peer_addr() 31 + .map(|a| a.to_string()) 32 + .unwrap_or_default(); 33 + tracing::info!("slim: client connected from {peer}"); 34 + 35 + match read_client_packet(&mut stream) { 36 + Ok((opcode, _body)) if opcode == "HELO" => { 37 + tracing::info!("slim: HELO from {peer}"); 38 + } 39 + Ok((opcode, _)) => { 40 + tracing::warn!("slim: expected HELO, got '{opcode}' from {peer}"); 41 + return; 42 + } 43 + Err(e) => { 44 + tracing::debug!("slim: read error from {peer}: {e}"); 45 + return; 46 + } 47 + } 48 + 49 + if let Err(e) = send_strm_start(&mut stream, http_port) { 50 + tracing::error!("slim: send STRM to {peer} failed: {e}"); 51 + return; 52 + } 53 + tracing::info!("slim: sent STRM to {peer} → http stream on :{http_port}"); 54 + 55 + // Read STAT / DSCO packets. Reply to every STMt heartbeat with audg so 56 + // squeezelite's 36-second "no messages from server" watchdog never fires. 57 + loop { 58 + match read_client_packet(&mut stream) { 59 + Ok((opcode, body)) => { 60 + if opcode == "STAT" && body.len() >= 4 { 61 + let ev = std::str::from_utf8(&body[..4]).unwrap_or("????"); 62 + tracing::debug!("slim: STAT {ev} from {peer}"); 63 + if ev == "STMt" { 64 + if let Err(e) = send_audg(&mut stream) { 65 + tracing::debug!("slim: audg error to {peer}: {e}"); 66 + break; 67 + } 68 + } 69 + } else if opcode == "DSCO" { 70 + tracing::info!("slim: DSCO from {peer}"); 71 + break; 72 + } else { 73 + tracing::debug!("slim: {opcode} from {peer}"); 74 + } 75 + } 76 + Err(_) => { 77 + tracing::info!("slim: {peer} disconnected"); 78 + break; 79 + } 80 + } 81 + } 82 + } 83 + 84 + fn read_client_packet(stream: &mut TcpStream) -> std::io::Result<(String, Vec<u8>)> { 85 + let mut opcode = [0u8; 4]; 86 + stream.read_exact(&mut opcode)?; 87 + 88 + let mut len_buf = [0u8; 4]; 89 + stream.read_exact(&mut len_buf)?; 90 + let len = u32::from_be_bytes(len_buf) as usize; 91 + 92 + let mut payload = vec![0u8; len]; 93 + if len > 0 { 94 + stream.read_exact(&mut payload)?; 95 + } 96 + Ok((String::from_utf8_lossy(&opcode).into_owned(), payload)) 97 + } 98 + 99 + fn send_server_packet( 100 + stream: &mut TcpStream, 101 + opcode: &[u8; 4], 102 + payload: &[u8], 103 + ) -> std::io::Result<()> { 104 + let total = 4usize + payload.len(); 105 + stream.write_all(&(total as u16).to_be_bytes())?; 106 + stream.write_all(opcode)?; 107 + stream.write_all(payload)?; 108 + Ok(()) 109 + } 110 + 111 + fn send_strm_start(stream: &mut TcpStream, http_port: u16) -> std::io::Result<()> { 112 + let request = b"GET /stream.pcm HTTP/1.0\r\n\r\n"; 113 + let mut payload = Vec::with_capacity(24 + request.len()); 114 + payload.push(b's'); // command: start 115 + payload.push(b'1'); // autostart 116 + payload.push(b'p'); // format: raw PCM 117 + payload.push(b'1'); // pcm_sample_size: 16-bit 118 + payload.push(b'3'); // pcm_sample_rate: 44100 Hz 119 + payload.push(b'2'); // pcm_channels: stereo 120 + payload.push(b'1'); // pcm_endianness: little-endian 121 + payload.push(255u8); // threshold: 255 KB 122 + payload.push(0u8); // spdif_enable 123 + payload.push(0u8); // transition_period 124 + payload.push(b'0'); // transition_type: none 125 + payload.push(0u8); // flags 126 + payload.push(0u8); // output_threshold 127 + payload.push(0u8); // slaves 128 + payload.extend_from_slice(&0x00010000u32.to_be_bytes()); // replay_gain = 1.0 129 + payload.extend_from_slice(&http_port.to_be_bytes()); 130 + payload.extend_from_slice(&0u32.to_be_bytes()); // server_ip = 0 → use slimproto_ip 131 + payload.extend_from_slice(request); 132 + send_server_packet(stream, b"strm", &payload) 133 + } 134 + 135 + fn send_audg(stream: &mut TcpStream) -> std::io::Result<()> { 136 + let mut payload = [0u8; 9]; 137 + payload[0..4].copy_from_slice(&0x00010000u32.to_be_bytes()); // left gain = 1.0 138 + payload[4..8].copy_from_slice(&0x00010000u32.to_be_bytes()); // right gain = 1.0 139 + send_server_packet(stream, b"audg", &payload) 140 + }
+2
crates/sys/src/lib.rs
··· 1149 1149 fn pcm_switch_sink(sink: c_int) -> c_uchar; 1150 1150 fn pcm_fifo_set_path(path: *const c_char); 1151 1151 fn pcm_airplay_set_host(host: *const c_char, port: c_ushort); 1152 + fn pcm_squeezelite_set_slim_port(port: c_ushort); 1153 + fn pcm_squeezelite_set_http_port(port: c_ushort); 1152 1154 fn beep_play(frequency: c_uint, duration: c_uint, amplitude: c_uint); 1153 1155 fn dsp_set_crossfeed_type(r#type: c_int); 1154 1156 fn dsp_eq_enable(enable: c_uchar);
+9
crates/sys/src/sound/pcm.rs
··· 5 5 pub const PCM_SINK_BUILTIN: i32 = 0; 6 6 pub const PCM_SINK_FIFO: i32 = 1; 7 7 pub const PCM_SINK_AIRPLAY: i32 = 2; 8 + pub const PCM_SINK_SQUEEZELITE: i32 = 3; 8 9 9 10 pub fn apply_settings() { 10 11 unsafe { ··· 66 67 // so leaking is acceptable here for a startup-time config call. 67 68 std::mem::forget(cpath); 68 69 } 70 + 71 + pub fn squeezelite_set_slim_port(port: u16) { 72 + unsafe { crate::pcm_squeezelite_set_slim_port(port) } 73 + } 74 + 75 + pub fn squeezelite_set_http_port(port: u16) { 76 + unsafe { crate::pcm_squeezelite_set_http_port(port) } 77 + }
+7 -1
crates/sys/src/types/user_settings.rs
··· 677 677 pub eq_band_settings: Option<Vec<EqBandSetting>>, 678 678 pub replaygain_settings: Option<ReplaygainSettings>, 679 679 pub compressor_settings: Option<CompressorSettings>, 680 - /// Audio output sink: "builtin" (default), "fifo", or "airplay" 680 + /// Audio output sink: "builtin" (default), "fifo", "airplay", or "squeezelite" 681 681 pub audio_output: Option<String>, 682 682 /// Path for the FIFO sink, e.g. "/tmp/rockbox.fifo" or "-" for stdout 683 683 pub fifo_path: Option<String>, ··· 685 685 pub airplay_host: Option<String>, 686 686 /// RAOP port on the receiver (default: 5000) 687 687 pub airplay_port: Option<u16>, 688 + /// Slim Protocol control port for the squeezelite sink (default: 3483) 689 + pub squeezelite_port: Option<u16>, 690 + /// HTTP audio stream port for the squeezelite sink (default: 9999) 691 + pub squeezelite_http_port: Option<u16>, 688 692 } 689 693 690 694 impl From<UserSettings> for NewGlobalSettings { ··· 722 726 fifo_path: None, 723 727 airplay_host: None, 724 728 airplay_port: None, 729 + squeezelite_port: None, 730 + squeezelite_http_port: None, 725 731 } 726 732 } 727 733 }
+1
firmware/SOURCES
··· 538 538 #if (CONFIG_PLATFORM & PLATFORM_HOSTED) 539 539 target/hosted/pcm-fifo.c 540 540 target/hosted/pcm-airplay.c 541 + target/hosted/pcm-squeezelite.c 541 542 #endif 542 543 #ifdef HAVE_SW_VOLUME_CONTROL 543 544 pcm_sw_volume.c
+4
firmware/export/pcm_sink.h
··· 55 55 #if (CONFIG_PLATFORM & PLATFORM_HOSTED) 56 56 PCM_SINK_FIFO, 57 57 PCM_SINK_AIRPLAY, 58 + PCM_SINK_SQUEEZELITE, 58 59 #endif 59 60 PCM_SINK_NUM 60 61 }; ··· 69 70 70 71 /* AirPlay (RAOP) sink — streams ALAC-encoded audio over RTP */ 71 72 extern struct pcm_sink airplay_pcm_sink; 73 + 74 + /* Squeezelite (Slim Protocol) sink — serves PCM via HTTP to squeezelite */ 75 + extern struct pcm_sink squeezelite_pcm_sink; 72 76 #endif
+4 -3
firmware/pcm.c
··· 79 79 */ 80 80 81 81 static struct pcm_sink* sinks[PCM_SINK_NUM] = { 82 - [PCM_SINK_BUILTIN] = &builtin_pcm_sink, 82 + [PCM_SINK_BUILTIN] = &builtin_pcm_sink, 83 83 #if (CONFIG_PLATFORM & PLATFORM_HOSTED) 84 - [PCM_SINK_FIFO] = &fifo_pcm_sink, 85 - [PCM_SINK_AIRPLAY] = &airplay_pcm_sink, 84 + [PCM_SINK_FIFO] = &fifo_pcm_sink, 85 + [PCM_SINK_AIRPLAY] = &airplay_pcm_sink, 86 + [PCM_SINK_SQUEEZELITE] = &squeezelite_pcm_sink, 86 87 #endif 87 88 }; 88 89 static enum pcm_sink_ids cur_sink = PCM_SINK_BUILTIN;
+213
firmware/target/hosted/pcm-squeezelite.c
··· 1 + /*************************************************************************** 2 + * PCM sink that streams raw S16LE stereo PCM to squeezelite via the Slim 3 + * Protocol (port 3483) + a plain HTTP audio endpoint (port 9999). 4 + * 5 + * Usage: 6 + * pcm_squeezelite_set_slim_port(3483); // optional, this is the default 7 + * pcm_squeezelite_set_http_port(9999); // optional, this is the default 8 + * pcm_switch_sink(PCM_SINK_SQUEEZELITE); 9 + * // Then run: squeezelite -s localhost 10 + * 11 + * Copyright (C) 2026 Rockbox contributors 12 + * 13 + * This program is free software; you can redistribute it and/or 14 + * modify it under the terms of the GNU General Public License 15 + * as published by the Free Software Foundation; either version 2 16 + * of the License, or (at your option) any later version. 17 + * 18 + * This software is distributed on an "AS IS" basis, WITHOUT WARRANTY OF ANY 19 + * KIND, either express or implied. 20 + * 21 + ****************************************************************************/ 22 + 23 + #include "autoconf.h" 24 + #include "config.h" 25 + 26 + #include <pthread.h> 27 + #include <stdbool.h> 28 + #include <stddef.h> 29 + #include <stdint.h> 30 + #include <time.h> 31 + #include <unistd.h> 32 + 33 + #include "pcm.h" 34 + #include "pcm-internal.h" 35 + #include "pcm_mixer.h" 36 + #include "pcm_sampr.h" 37 + #include "pcm_sink.h" 38 + 39 + #define LOGF_ENABLE 40 + #include "logf.h" 41 + 42 + /* Rust C API — symbols provided by the rockbox-slim crate via librockbox_cli.a */ 43 + extern void pcm_squeezelite_set_slim_port(uint16_t port); 44 + extern void pcm_squeezelite_set_http_port(uint16_t port); 45 + extern int pcm_squeezelite_start(void); 46 + extern int pcm_squeezelite_write(const uint8_t *data, size_t len); 47 + extern void pcm_squeezelite_stop(void); 48 + extern void pcm_squeezelite_close(void); 49 + 50 + static const void *pcm_data = NULL; 51 + static size_t pcm_size = 0; 52 + 53 + static pthread_mutex_t squeezelite_mtx; 54 + static pthread_t squeezelite_tid; 55 + static volatile bool squeezelite_running = false; 56 + static volatile bool squeezelite_stop = false; 57 + 58 + /* Real-time pacing state — reset on every sink_dma_start(). */ 59 + static struct timespec play_start; 60 + static uint64_t play_bytes; 61 + 62 + /* Actual sample rate set by sink_set_freq(); defaults to 44100. 63 + * bytes_per_sec = sample_rate * 2 channels * 2 bytes/sample. */ 64 + static unsigned long current_sample_rate = 44100; 65 + 66 + static void *squeezelite_thread(void *arg) 67 + { 68 + (void)arg; 69 + 70 + while (!squeezelite_stop) { 71 + pthread_mutex_lock(&squeezelite_mtx); 72 + const void *data = pcm_data; 73 + size_t size = pcm_size; 74 + pcm_data = NULL; 75 + pcm_size = 0; 76 + pthread_mutex_unlock(&squeezelite_mtx); 77 + 78 + if (data && size > 0) { 79 + if (pcm_squeezelite_write((const uint8_t *)data, size) < 0) { 80 + logf("pcm-squeezelite: write error"); 81 + squeezelite_stop = true; 82 + break; 83 + } 84 + 85 + /* Pace to real-time so the DMA loop does not drain the entire 86 + * track instantly. We track total bytes written and sleep 87 + * whenever we are ahead of the expected wall-clock position. 88 + * 89 + * bytes_per_sec = sample_rate * 2 channels * 2 bytes/sample */ 90 + play_bytes += size; 91 + uint64_t bps = (uint64_t)current_sample_rate * 4; 92 + uint64_t expected_us = play_bytes * 1000000ULL / bps; 93 + 94 + struct timespec now; 95 + clock_gettime(CLOCK_MONOTONIC, &now); 96 + /* Use signed arithmetic to avoid uint64_t wrap when tv_nsec 97 + * rolls over (happens every second on a monotonic clock). */ 98 + int64_t elapsed_us = 99 + (int64_t)(now.tv_sec - play_start.tv_sec) * 1000000LL + 100 + ((int64_t)now.tv_nsec - (int64_t)play_start.tv_nsec) / 1000LL; 101 + 102 + if (elapsed_us >= 0 && expected_us > (uint64_t)elapsed_us) { 103 + usleep((useconds_t)(expected_us - (uint64_t)elapsed_us)); 104 + } 105 + } 106 + 107 + if (squeezelite_stop) 108 + break; 109 + 110 + pthread_mutex_lock(&squeezelite_mtx); 111 + bool got_more = pcm_play_dma_complete_callback(PCM_DMAST_OK, 112 + &pcm_data, &pcm_size); 113 + pthread_mutex_unlock(&squeezelite_mtx); 114 + 115 + if (!got_more) { 116 + logf("pcm-squeezelite: no more PCM data"); 117 + break; 118 + } 119 + 120 + pcm_play_dma_status_callback(PCM_DMAST_STARTED); 121 + } 122 + 123 + squeezelite_running = false; 124 + return NULL; 125 + } 126 + 127 + static void sink_dma_init(void) 128 + { 129 + pthread_mutexattr_t attr; 130 + pthread_mutexattr_init(&attr); 131 + pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE); 132 + pthread_mutex_init(&squeezelite_mtx, &attr); 133 + pthread_mutexattr_destroy(&attr); 134 + } 135 + 136 + static void sink_dma_postinit(void) 137 + { 138 + } 139 + 140 + static void sink_set_freq(uint16_t freq) 141 + { 142 + current_sample_rate = hw_freq_sampr[freq]; 143 + logf("pcm-squeezelite: sample rate %lu Hz", current_sample_rate); 144 + } 145 + 146 + static void sink_lock(void) 147 + { 148 + pthread_mutex_lock(&squeezelite_mtx); 149 + } 150 + 151 + static void sink_unlock(void) 152 + { 153 + pthread_mutex_unlock(&squeezelite_mtx); 154 + } 155 + 156 + static void sink_dma_start(const void *addr, size_t size) 157 + { 158 + logf("pcm-squeezelite: start (%p, %zu)", addr, size); 159 + 160 + if (pcm_squeezelite_start() < 0) { 161 + logf("pcm-squeezelite: server start failed"); 162 + return; 163 + } 164 + 165 + /* Reset real-time pacing for this track. */ 166 + clock_gettime(CLOCK_MONOTONIC, &play_start); 167 + play_bytes = 0; 168 + 169 + pthread_mutex_lock(&squeezelite_mtx); 170 + pcm_data = addr; 171 + pcm_size = size; 172 + pthread_mutex_unlock(&squeezelite_mtx); 173 + 174 + squeezelite_stop = false; 175 + squeezelite_running = true; 176 + pthread_create(&squeezelite_tid, NULL, squeezelite_thread, NULL); 177 + } 178 + 179 + static void sink_dma_stop(void) 180 + { 181 + logf("pcm-squeezelite: stop"); 182 + 183 + squeezelite_stop = true; 184 + 185 + if (squeezelite_running) { 186 + pthread_join(squeezelite_tid, NULL); 187 + squeezelite_running = false; 188 + } 189 + 190 + pthread_mutex_lock(&squeezelite_mtx); 191 + pcm_data = NULL; 192 + pcm_size = 0; 193 + pthread_mutex_unlock(&squeezelite_mtx); 194 + 195 + pcm_squeezelite_stop(); 196 + } 197 + 198 + struct pcm_sink squeezelite_pcm_sink = { 199 + .caps = { 200 + .samprs = hw_freq_sampr, 201 + .num_samprs = HW_NUM_FREQ, 202 + .default_freq = HW_FREQ_DEFAULT, 203 + }, 204 + .ops = { 205 + .init = sink_dma_init, 206 + .postinit = sink_dma_postinit, 207 + .set_freq = sink_set_freq, 208 + .lock = sink_lock, 209 + .unlock = sink_unlock, 210 + .play = sink_dma_start, 211 + .stop = sink_dma_stop, 212 + }, 213 + };