Headless Rust gRPC daemon to drive Bluetooth, HLS/DASH playback, and snapcast/shairport-sync/squeezelite on Raspberry Pi audio rigs.
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}