Headless Rust gRPC daemon to drive Bluetooth, HLS/DASH playback, and snapcast/shairport-sync/squeezelite on Raspberry Pi audio rigs.
0

Configure Feed

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

zerod / crates / stream / src / sources / librespot.rs
4.4 kB 130 lines
1//! Librespot subprocess source. 2//! 3//! Spawns `librespot --backend pipe --format S16 --device -` so the child 4//! writes raw interleaved S16LE PCM at 44.1 kHz / 2ch to stdout. A tokio 5//! task reads in 8 KiB chunks, converts byte pairs to `i16` via 6//! `from_le_bytes` (endian-safe across cross-compile targets), applies the 7//! existing per-stream gain, and forwards into the same `AudioSink` as 8//! HLS/DASH playback. Stderr is mirrored at TRACE so unsolicited 9//! librespot chatter doesn't dominate logs. 10//! 11//! The child is held via `tokio::process::Command::kill_on_drop(true)` 12//! so an `abort()` from `Player::cancel()` reliably reaps it. 13 14use anyhow::{anyhow, Context, Result}; 15use std::process::Stdio; 16use std::sync::atomic::Ordering; 17use std::sync::Arc; 18use tokio::io::AsyncReadExt; 19use tokio::process::{Child, Command}; 20 21use super::LibrespotConfig; 22use crate::player::{ 23 apply_gain_pub, build_sink_pub, global_volume, install, runtime, PlaybackSource, Player, 24 PlayerState, 25}; 26use crate::sink::PcmFormat; 27 28pub fn spotify_start(cfg: LibrespotConfig) -> Result<()> { 29 let sink = build_sink_pub(&cfg.output)?; 30 let player = Arc::new(Player::new( 31 format!("spotify://{}", cfg.name), 32 cfg.output.clone(), 33 sink, 34 PlaybackSource::Spotify, 35 )); 36 let runner = player.clone(); 37 let task = runtime().spawn(async move { run_librespot(runner, cfg).await }); 38 install(player, task); 39 Ok(()) 40} 41 42pub fn spotify_stop() -> bool { 43 crate::player::stop() 44} 45 46async fn run_librespot(player: Arc<Player>, cfg: LibrespotConfig) { 47 if let Err(e) = run_librespot_inner(&player, cfg).await { 48 player.record_error(format!("{e:#}")); 49 } else { 50 player.set_state(PlayerState::Stopped); 51 } 52 player.sink.close(); 53} 54 55async fn run_librespot_inner(player: &Arc<Player>, cfg: LibrespotConfig) -> Result<()> { 56 player.set_state(PlayerState::Buffering); 57 // librespot --backend pipe emits 44100 / 2ch S16LE regardless of 58 // source quality. Fix the sink format up front. 59 let fmt = PcmFormat { 60 sample_rate: 44_100, 61 channels: 2, 62 }; 63 player.sink.set_format(fmt)?; 64 65 let mut child = spawn_librespot(&cfg)?; 66 let mut stdout = child 67 .stdout 68 .take() 69 .ok_or_else(|| anyhow!("librespot: no stdout"))?; 70 if let Some(stderr) = child.stderr.take() { 71 runtime().spawn(forward_stderr(stderr)); 72 } 73 74 player.set_state(PlayerState::Playing); 75 76 let mut buf = vec![0u8; 8192]; 77 loop { 78 if player.stop_flag.load(Ordering::SeqCst) { 79 break; 80 } 81 let n = match stdout.read(&mut buf).await { 82 Ok(0) => break, // EOF — child exited 83 Ok(n) => n, 84 Err(e) => return Err(anyhow!("librespot stdout read: {e}")), 85 }; 86 let mut samples = Vec::with_capacity(n / 2); 87 for pair in buf[..n].chunks_exact(2) { 88 samples.push(i16::from_le_bytes([pair[0], pair[1]])); 89 } 90 let vol = global_volume(); 91 if vol < 100 { 92 apply_gain_pub(&mut samples, vol); 93 } 94 player.sink.write(&samples)?; 95 } 96 97 let _ = child.kill().await; 98 Ok(()) 99} 100 101fn spawn_librespot(cfg: &LibrespotConfig) -> Result<Child> { 102 let mut cmd = Command::new(&cfg.binary); 103 cmd.kill_on_drop(true); 104 // TODO verify these flags against the installed librespot version. 105 // 0.4.x ships `--backend pipe` + `--format S16`; older builds used 106 // `--backend pipe-stdout`. CI Pi smoke test should catch a regression. 107 cmd.arg("--name").arg(&cfg.name); 108 cmd.arg("--bitrate").arg(cfg.bitrate.to_string()); 109 cmd.arg("--backend").arg("pipe"); 110 cmd.arg("--device").arg("-"); 111 cmd.arg("--format").arg("S16"); 112 cmd.arg("--initial-volume").arg("100"); 113 if !cfg.cache_path.is_empty() { 114 cmd.arg("--cache").arg(&cfg.cache_path); 115 // Cache credentials but not raw audio chunks — disk-cheap on Pi. 116 cmd.arg("--disable-audio-cache"); 117 } 118 cmd.stdout(Stdio::piped()); 119 cmd.stderr(Stdio::piped()); 120 cmd.spawn() 121 .with_context(|| format!("spawn librespot binary `{}`", cfg.binary)) 122} 123 124async fn forward_stderr(stderr: tokio::process::ChildStderr) { 125 use tokio::io::{AsyncBufReadExt, BufReader}; 126 let mut lines = BufReader::new(stderr).lines(); 127 while let Ok(Some(line)) = lines.next_line().await { 128 tracing::debug!("librespot: {line}"); 129 } 130}