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