Skip to main content

snapcast_client/
lib.rs

1#![deny(unsafe_code)]
2#![warn(clippy::redundant_closure)]
3#![warn(clippy::implicit_clone)]
4#![warn(clippy::uninlined_format_args)]
5#![warn(missing_docs)]
6
7//! Snapcast client library — embeddable synchronized multiroom audio client.
8//!
9//! See also: [`snapcast-server`](https://docs.rs/snapcast-server) for the server library.
10//!
11//! # Example
12//! ```no_run
13//! use snapcast_client::{SnapClient, ClientConfig, ClientEvent, ClientCommand};
14//!
15//! # async fn example() -> anyhow::Result<()> {
16//! let config = ClientConfig::default();
17//! let (mut client, mut events, mut audio_rx) = SnapClient::new(config);
18//! let cmd = client.command_sender();
19//!
20//! // React to events in a separate task
21//! tokio::spawn(async move {
22//!     while let Some(event) = events.recv().await {
23//!         match event {
24//!             ClientEvent::VolumeChanged { volume, muted } => {
25//!                 println!("Volume: {volume}, muted: {muted}");
26//!             }
27//!             _ => {}
28//!         }
29//!     }
30//! });
31//!
32//! // Stop on Ctrl-C
33//! let stop = cmd.clone();
34//! tokio::spawn(async move {
35//!     tokio::signal::ctrl_c().await.ok();
36//!     stop.send(ClientCommand::Stop).await.ok();
37//! });
38//!
39//! client.run().await?;
40//! # Ok(())
41//! # }
42//! ```
43
44pub mod config;
45pub mod connection;
46pub(crate) mod controller;
47#[cfg(feature = "encryption")]
48pub(crate) mod crypto;
49pub mod decoder;
50pub(crate) mod double_buffer;
51pub mod stream;
52pub mod time_provider;
53
54#[cfg(feature = "resampler")]
55pub mod resampler;
56
57use tokio::sync::mpsc;
58
59const EVENT_CHANNEL_SIZE: usize = 256;
60const COMMAND_CHANNEL_SIZE: usize = 64;
61const AUDIO_CHANNEL_SIZE: usize = 256;
62
63// Re-export proto types that embedders need
64#[cfg(feature = "custom-protocol")]
65pub use snapcast_proto::CustomMessage;
66pub use snapcast_proto::SampleFormat;
67pub use snapcast_proto::{DEFAULT_STREAM_PORT, PROTOCOL_VERSION};
68
69/// Interleaved f32 audio frame produced by the client library.
70#[derive(Debug, Clone)]
71pub struct AudioFrame {
72    /// Interleaved f32 samples (channel-interleaved).
73    pub samples: Vec<f32>,
74    /// Sample rate in Hz.
75    pub sample_rate: u32,
76    /// Number of channels.
77    pub channels: u16,
78    /// Server timestamp in microseconds (for sync).
79    pub timestamp_usec: i64,
80}
81
82/// Events emitted by the client to the consumer.
83#[derive(Debug, Clone)]
84pub enum ClientEvent {
85    /// Connected to server.
86    Connected {
87        /// Server hostname or IP.
88        host: String,
89        /// Server port.
90        port: u16,
91    },
92    /// Disconnected from server.
93    Disconnected {
94        /// Reason for disconnection.
95        reason: String,
96    },
97    /// Audio stream started with the given format.
98    StreamStarted {
99        /// Codec name (e.g. "flac", "opus").
100        codec: String,
101        /// PCM sample format.
102        format: SampleFormat,
103    },
104    /// Server settings received or updated.
105    ServerSettings {
106        /// Playout buffer in milliseconds.
107        buffer_ms: i32,
108        /// Additional latency in milliseconds.
109        latency: i32,
110        /// Volume (0–100).
111        volume: u16,
112        /// Mute state.
113        muted: bool,
114    },
115    /// Volume changed (from server or local).
116    VolumeChanged {
117        /// Volume (0–100).
118        volume: u16,
119        /// Mute state.
120        muted: bool,
121    },
122    /// Time sync completed.
123    TimeSyncComplete {
124        /// Clock difference to server in milliseconds.
125        diff_ms: f64,
126    },
127    #[cfg(feature = "custom-protocol")]
128    /// Custom message received from server.
129    CustomMessage(snapcast_proto::CustomMessage),
130}
131
132/// Commands the consumer sends to the client.
133#[derive(Debug, Clone)]
134pub enum ClientCommand {
135    /// Set volume (0–100) and mute state.
136    SetVolume {
137        /// Volume (0–100).
138        volume: u16,
139        /// Mute state.
140        muted: bool,
141    },
142    /// Send a custom message to the server.
143    #[cfg(feature = "custom-protocol")]
144    SendCustom(snapcast_proto::CustomMessage),
145    /// Stop the client gracefully.
146    Stop,
147}
148
149/// Configuration for the embeddable client.
150#[derive(Debug, Clone)]
151pub struct ClientConfig {
152    /// Connection scheme. Only "tcp" is supported for audio streaming.
153    pub scheme: String,
154    /// Server hostname or IP (empty = mDNS discovery).
155    pub host: String,
156    /// Server port. Default: 1704.
157    pub port: u16,
158    /// Optional authentication for Hello handshake.
159    pub auth: Option<crate::config::Auth>,
160    /// Server CA certificate for TLS verification.
161    #[cfg(feature = "tls")]
162    pub server_certificate: Option<std::path::PathBuf>,
163    /// Client certificate (PEM).
164    #[cfg(feature = "tls")]
165    pub certificate: Option<std::path::PathBuf>,
166    /// Client private key (PEM).
167    #[cfg(feature = "tls")]
168    pub certificate_key: Option<std::path::PathBuf>,
169    /// Password for encrypted private key.
170    #[cfg(feature = "tls")]
171    pub key_password: Option<String>,
172    /// Instance id (for multiple clients on one host).
173    pub instance: u32,
174    /// Unique host identifier (default: MAC address).
175    pub host_id: String,
176    /// Additional latency in milliseconds (subtracted from buffer).
177    pub latency: i32,
178    /// Client name sent in Hello. Default: "Snapclient".
179    pub client_name: String,
180    /// Pre-shared key for f32lz4 decryption. `None` = auto-detect from env SNAPCAST_PSK.
181    #[cfg(feature = "encryption")]
182    pub encryption_psk: Option<String>,
183}
184
185impl Default for ClientConfig {
186    fn default() -> Self {
187        Self {
188            scheme: snapcast_proto::SCHEME_TCP.into(),
189            host: String::new(),
190            port: snapcast_proto::DEFAULT_STREAM_PORT,
191            auth: None,
192            #[cfg(feature = "tls")]
193            server_certificate: None,
194            #[cfg(feature = "tls")]
195            certificate: None,
196            #[cfg(feature = "tls")]
197            certificate_key: None,
198            #[cfg(feature = "tls")]
199            key_password: None,
200            instance: 1,
201            host_id: String::new(),
202            latency: 0,
203            client_name: snapcast_proto::DEFAULT_CLIENT_NAME.into(),
204            #[cfg(feature = "encryption")]
205            encryption_psk: None,
206        }
207    }
208}
209
210/// The embeddable Snapcast client.
211pub struct SnapClient {
212    config: ClientConfig,
213    event_tx: mpsc::Sender<ClientEvent>,
214    command_tx: mpsc::Sender<ClientCommand>,
215    command_rx: Option<mpsc::Receiver<ClientCommand>>,
216    audio_tx: mpsc::Sender<AudioFrame>,
217    /// Shared time provider — accessible by the binary for audio output.
218    pub time_provider: std::sync::Arc<std::sync::Mutex<time_provider::TimeProvider>>,
219    /// Shared stream — accessible by the binary for audio output.
220    pub stream: std::sync::Arc<std::sync::Mutex<stream::Stream>>,
221}
222
223impl SnapClient {
224    /// Create a new client. Returns the client, event receiver, and audio output receiver.
225    pub fn new(
226        config: ClientConfig,
227    ) -> (
228        Self,
229        mpsc::Receiver<ClientEvent>,
230        mpsc::Receiver<AudioFrame>,
231    ) {
232        let (event_tx, event_rx) = mpsc::channel(EVENT_CHANNEL_SIZE);
233        let (command_tx, command_rx) = mpsc::channel(COMMAND_CHANNEL_SIZE);
234        let (audio_tx, audio_rx) = mpsc::channel(AUDIO_CHANNEL_SIZE);
235        let time_provider =
236            std::sync::Arc::new(std::sync::Mutex::new(time_provider::TimeProvider::new()));
237        let stream = std::sync::Arc::new(std::sync::Mutex::new(stream::Stream::new(
238            SampleFormat::default(),
239        )));
240        let client = Self {
241            config,
242            event_tx,
243            command_tx,
244            command_rx: Some(command_rx),
245            audio_tx,
246            time_provider,
247            stream,
248        };
249        (client, event_rx, audio_rx)
250    }
251
252    /// Get a cloneable command sender.
253    pub fn command_sender(&self) -> mpsc::Sender<ClientCommand> {
254        self.command_tx.clone()
255    }
256
257    /// Run the client. Blocks until stopped or a fatal error occurs.
258    pub async fn run(&mut self) -> anyhow::Result<()> {
259        let command_rx = self
260            .command_rx
261            .take()
262            .ok_or_else(|| anyhow::anyhow!("run() already called"))?;
263
264        let mut ctrl = controller::Controller::new(
265            self.config.clone(),
266            self.event_tx.clone(),
267            command_rx,
268            self.audio_tx.clone(),
269            std::sync::Arc::clone(&self.time_provider),
270            std::sync::Arc::clone(&self.stream),
271        )?;
272        ctrl.run().await
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    #[tokio::test]
281    async fn run_rejects_websocket_audio_scheme() {
282        let config = ClientConfig {
283            scheme: snapcast_proto::SCHEME_WS.into(),
284            host: "localhost".into(),
285            port: snapcast_proto::DEFAULT_HTTP_PORT,
286            ..ClientConfig::default()
287        };
288        let (mut client, _events, _audio_rx) = SnapClient::new(config);
289
290        let err = client.run().await.unwrap_err();
291        assert!(err.to_string().contains("websocket audio transport"));
292    }
293}