snapcast-server 0.5.0

Snapcast server library — embeddable synchronized multiroom audio server
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
# snapcast-rs

[![CI](https://github.com/metaneutrons/snapcast-rs/actions/workflows/ci.yml/badge.svg)](https://github.com/metaneutrons/snapcast-rs/actions/workflows/ci.yml)
[![crates.io](https://img.shields.io/crates/v/snapcast-client.svg)](https://crates.io/crates/snapcast-client)
[![crates.io](https://img.shields.io/crates/v/snapcast-server.svg)](https://crates.io/crates/snapcast-server)
[![docs.rs](https://docs.rs/snapcast-client/badge.svg)](https://docs.rs/snapcast-client)
[![docs.rs](https://docs.rs/snapcast-server/badge.svg)](https://docs.rs/snapcast-server)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue.svg)](LICENSE)

> **⚠️ Pre-1.0 — APIs may break on minor version bumps.** Until version 1.0, minor releases (e.g. 0.3 → 0.4) may contain breaking changes to the public API. Pin your dependency to a specific minor version if you need stability.

A Rust reimplementation of [Snapcast](https://github.com/snapcast/snapcast), the excellent multiroom audio system created by [Johannes Pohl (badaix)](https://github.com/badaix). Snapcast synchronizes audio playback across multiple devices with sub-millisecond precision — turning any collection of speakers into a perfectly synced whole-home audio system.

This project exists primarily to serve as a native Rust dependency for [SnapDog](https://github.com/metaneutrons/SnapDogRust), a multiroom audio appliance. Rather than shelling out to C++ binaries or bridging through FFI, SnapDog embeds the Snapcast protocol directly as a library — receiving audio, encoding it, distributing it to clients, and controlling playback, all within a single Rust process.

To make this possible, snapcast-rs separates the protocol engine from the application shell. The **library crates** (`snapcast-client`, `snapcast-server`) implement the Snapcast binary protocol, audio encoding/decoding, time synchronization, and mDNS discovery — but own no audio devices, open no HTTP ports, and read no config files. They communicate exclusively through typed Rust channels, making them straightforward to embed in any application.

The **binary crates** (`snapclient-rs`, `snapserver-rs`) are thin wrappers around these libraries. They add the things a standalone application needs: reading audio from pipes and processes, serving the JSON-RPC control API over HTTP and TCP, hosting the Snapweb UI, and outputting audio through platform-native backends via cpal. They are fully functional replacements for the original C++ `snapserver` and `snapclient`.

The result is a Snapcast implementation that works both as a drop-in replacement for the original and as an embeddable building block for Rust applications that need synchronized multiroom audio.


snapcast-rs is fully compatible with the original C++ Snapcast when using standard codecs (PCM, FLAC, Opus, Vorbis). However, three optional features break compatibility:

| Feature | What it does | C++ behavior |
|---------|-------------|--------------|
| `f32lz4` | 32-bit float LZ4 codec | C++ clients reject unknown codec |
| `custom-protocol` | Application-defined message types (9+) | C++ clients silently ignore |
| `encryption` | ChaCha20-Poly1305 encrypted f32lz4 | C++ clients reject unknown codec |

If you enable `f32lz4` or `encryption` on the server, C++ clients cannot decode the audio. To prevent them from auto-connecting via mDNS, change the service type:

```rust
let config = ServerConfig {
    mdns_service_type: "_myapp._tcp.local.".into(),
    ..ServerConfig::default()
};
```

For full interoperability with C++ clients, use `--codec flac` or `--codec pcm` and leave `custom-protocol` and `encryption` disabled.

## Architecture

```
snapcast-rs/
├── snapcast-proto      Protocol: binary message serialization
├── snapcast-client     Client library: embeddable, f32 audio output
├── snapcast-server     Server library: embeddable, f32 audio input
├── snapclient-rs       Client binary: cpal audio output
├── snapserver-rs       Server binary: stream readers, JSON-RPC, HTTP
└── snapcast-tests      Integration tests
```

| Crate | Role | Docs |
|-------|------|------|
| [snapcast-proto]crates/snapcast-proto | Binary protocol, message serialization | [docs.rs]https://docs.rs/snapcast-proto |
| [snapcast-client]crates/snapcast-client | Client library: embeddable, f32 audio output | [docs.rs]https://docs.rs/snapcast-client |
| [snapcast-server]crates/snapcast-server | Server library: embeddable, f32 audio input | [docs.rs]https://docs.rs/snapcast-server |
| [snapclient-rs]crates/snapclient-rs | Client binary: cpal audio output ||
| [snapserver-rs]crates/snapserver-rs | Server binary: stream readers, JSON-RPC, HTTP ||
| [snapcast-tests]crates/snapcast-tests | Integration tests ||

Both libraries are pure audio engines — no device I/O, no HTTP, no config files.

## Client Library API

```rust
use snapcast_client::{SnapClient, ClientConfig, ClientEvent, ClientCommand};

let config = ClientConfig {
    host: "192.168.1.50".into(),
    port: 1704,
    ..ClientConfig::default()
};

// Create client — returns event receiver and audio output receiver
let (mut client, events, audio_rx) = SnapClient::new(config);

// Shared state for direct audio device access
let stream = Arc::clone(&client.stream);           // time-synced PCM buffer
let time_provider = Arc::clone(&client.time_provider); // server clock sync

// Run (blocks, reconnects on error)
tokio::spawn(async move { client.run().await });

// Events
match event {
    ClientEvent::Connected { host, port } => {}
    ClientEvent::Disconnected { reason } => {}
    ClientEvent::StreamStarted { codec, format } => {}
    ClientEvent::ServerSettings { buffer_ms, latency, volume, muted } => {}
    ClientEvent::VolumeChanged { volume, muted } => {}
    ClientEvent::TimeSyncComplete { diff_ms } => {}
}

// Commands
cmd.send(ClientCommand::SetVolume { volume: 80, muted: false }).await;
cmd.send(ClientCommand::Stop).await;
```

### Client Config

```rust
ClientConfig {
    scheme: String,            // "tcp", "ws", or "wss" (default: "tcp")
    host: String,              // server host (empty = mDNS discovery)
    port: u16,                 // default: 1704
    auth: Option<Auth>,        // Basic auth for Hello handshake
    server_certificate: Option<PathBuf>, // CA cert for TLS verification
    certificate: Option<PathBuf>,        // client cert (PEM)
    certificate_key: Option<PathBuf>,    // client key (PEM)
    key_password: Option<String>,        // key password
    instance: u32,             // for multiple clients on one host
    host_id: String,           // unique identifier (default: MAC)
    latency: i32,              // additional latency offset (ms)
    mdns_service_type: String, // default: "_snapcast._tcp.local."
    client_name: String,       // default: "Snapclient"
    encryption_psk: Option<String>, // f32lz4 encryption (feature: encryption)
}
```

### Client Features

| Feature     | Default | C dep | Description |
|-------------|---------|-------|-------------|
| `f32lz4`    || none  | f32 LZ4 codec (lz4_flex) |
| `mdns`      || none  | mDNS server discovery |
| `websocket` || none  | WebSocket connection |
| `tls`       || none  | WSS (WebSocket + TLS) |
| `resampler` || none  | Sample rate conversion (rubato) |
| `custom-protocol` || none | Custom binary messages (type 9+) |
| `encryption` || none | ChaCha20-Poly1305 encrypted f32lz4 |

## Server Library API

```rust
use snapcast_server::{SnapServer, ServerConfig, ServerEvent, ServerCommand, AudioFrame, AudioData, StreamConfig};

let config = ServerConfig {
    codec: "flac".into(),
    auth: Some(Arc::new(StaticAuthValidator::new(users, roles))),
    ..ServerConfig::default()
};

// Create server — returns event receiver
let (mut server, events) = SnapServer::new(config);

// Add audio streams (each gets its own encoder)
let audio_tx = server.add_stream("default");

// Per-stream codec override
let zone2_tx = server.add_stream_with_config("Zone2", StreamConfig {
    codec: Some("f32lz4".into()),
    ..Default::default()
});

// Run (blocks until Stop)
tokio::spawn(async move { server.run().await });

// Typed commands
cmd.send(ServerCommand::SetClientVolume { client_id, volume: 80, muted: false }).await;
cmd.send(ServerCommand::SetClientLatency { client_id, latency: 50 }).await;
cmd.send(ServerCommand::SetClientName { client_id, name }).await;
cmd.send(ServerCommand::SetGroupStream { group_id, stream_id }).await;
cmd.send(ServerCommand::SetGroupMute { group_id, muted: true }).await;
cmd.send(ServerCommand::DeleteClient { client_id }).await;
let (tx, rx) = oneshot::channel();
cmd.send(ServerCommand::GetStatus { response_tx: tx }).await;

// Push f32 audio directly
audio_tx.send(AudioFrame {
    data: AudioData::F32(samples),
    timestamp_usec,
}).await;

// Reactive events
match event {
    ServerEvent::ClientConnected { id, name, mac } => {}
    ServerEvent::ClientDisconnected { id } => {}
    ServerEvent::ClientVolumeChanged { client_id, volume, muted } => {}
    ServerEvent::ClientLatencyChanged { client_id, latency } => {}
    ServerEvent::ClientNameChanged { client_id, name } => {}
    ServerEvent::GroupStreamChanged { group_id, stream_id } => {}
    ServerEvent::GroupMuteChanged { group_id, muted } => {}
    ServerEvent::StreamStatus { stream_id, status } => {}
}
```

### Server Config

```rust
ServerConfig {
    stream_port: u16,          // default: 1704
    buffer_ms: u32,            // default: 1000
    codec: String,             // default: "flac" (feature-dependent: flac > f32lz4 > pcm)
    sample_format: String,     // default: "48000:16:2"
    mdns_service_type: String, // default: "_snapcast._tcp.local."
    auth: Option<Arc<dyn AuthValidator>>, // default: None (no auth)
    encryption_psk: Option<String>, // f32lz4 encryption (feature: encryption)
}
```

### Per-Stream Config

```rust
StreamConfig {
    codec: Option<String>,         // override server codec (e.g. "f32lz4", "flac")
    sample_format: Option<String>, // override server format (e.g. "48000:32:2")
}
```

### Server Features

| Feature  | Default | C dep     | Description |
|----------|---------|-----------|-------------|
| `f32lz4` || none      | f32 LZ4 codec (lz4_flex) |
| `mdns`   || none      | mDNS service advertisement |
| `flac`   || libFLAC   | FLAC encoding |
| `opus`   || libopus   | Opus encoding |
| `vorbis` || libvorbis | Vorbis encoding |
| `custom-protocol` || none | Custom binary messages (type 9+) |
| `encryption` || none | ChaCha20-Poly1305 encrypted f32lz4 |

### Authentication

The server supports streaming client authentication matching the C++ implementation:

```rust
use snapcast_server::auth::{AuthValidator, StaticAuthValidator, User, Role};

// Config-based auth (users/roles from config file)
let auth = StaticAuthValidator::new(
    vec![User { name: "player".into(), password: "secret".into(), role: "streaming".into() }],
    vec![Role { name: "streaming".into(), permissions: vec!["Streaming".into()] }],
);
let config = ServerConfig {
    auth: Some(Arc::new(auth)),
    ..ServerConfig::default()
};

// Or implement AuthValidator for custom auth (database, LDAP, etc.)
impl AuthValidator for MyValidator {
    fn validate(&self, scheme: &str, param: &str) -> Result<AuthResult, AuthError> {
        // your logic here
    }
}
```

Clients send Basic auth in the Hello handshake. The server validates credentials and checks the `"Streaming"` permission. Unauthenticated or unauthorized clients receive Error(401/403) and are disconnected.

### Network Ports

| Port | Protocol | Owner | Purpose |
|------|----------|-------|---------|
| 1704 | TCP | Library | Binary protocol (audio + time sync) |
| 1705 | TCP | Binary | JSON-RPC control |
| 1780 | HTTP/WS | Binary | JSON-RPC + Snapweb UI |

Libraries open only port 1704. JSON-RPC/HTTP are binary-only.

## Codecs

| Codec  | Default | C dep | Precision | Latency |
|--------|---------|-------|-----------|---------|
| PCM    | ✅ always | none | 16/24/32-bit | zero |
| f32lz4 | optional | none | 32-bit float | zero |
| FLAC   | ✅ default | libFLAC | 16/24-bit | 24ms (block size) |
| Opus   | optional | libopus | lossy | 20ms |
| Vorbis | optional | libvorbis | lossy | variable |

f32lz4 path (zero conversion, full precision):
```
f32 → LZ4 compress → network → LZ4 decompress → f32
```

### Bandwidth Comparison

**48 kHz, 16-bit, stereo:**

| Codec  | Precision | Bandwidth | vs PCM |
|--------|-----------|-----------|--------|
| PCM    | 16-bit    | 1,536 kbit/s | 100% |
| FLAC   | 16-bit    | ~700 kbit/s | ~45% |
| f32lz4 | 32-bit float | ~1,800 kbit/s | ~120% |

**96 kHz, 24-bit, stereo:**

| Codec  | Precision | Bandwidth | vs PCM |
|--------|-----------|-----------|--------|
| PCM    | 24-bit    | 4,608 kbit/s | 100% |
| FLAC   | 24-bit    | ~2,500 kbit/s | ~55% |
| f32lz4 | 32-bit float | ~3,600 kbit/s | ~78% |

f32lz4 trades bandwidth for precision (32-bit float) and zero conversion latency. On a LAN (100+ Mbit/s) the extra bandwidth is negligible. On WiFi it's still fine.

For bandwidth-constrained networks: use FLAC. For quality + simplicity: f32lz4.

> ⚠️ **f32lz4 is not compatible with the original C++ Snapcast.** C++ clients/servers do not recognize this codec. Use `--codec flac` or `--codec pcm` for interoperability with C++ Snapcast.

## Custom Binary Protocol (`--features custom-protocol`)

> ⚠️ **snapcast-rs only.** This feature extends the Snapcast binary protocol with application-defined message types. It is not part of the original C++ Snapcast.

C++ Snapcast safely ignores unknown message types — the factory returns `nullptr` for any unrecognized type, and the connection logs a warning and continues:

```cpp
// C++ snapcast: common/message/factory.hpp
switch (base_message.type) {
    case message_type::kCodecHeader: ...
    case message_type::kTime: ...
    // ...
    default:
        return nullptr;  // unknown types silently skipped
}
```

This means Rust servers can send custom messages to Rust clients while C++ clients on the same server simply ignore them.

### Use Case: Client-Side EQ

A Rust-based multiroom system (e.g. [SnapDog](https://github.com/metaneutrons/SnapDogRust)) can push per-client EQ settings through the binary protocol — no JSON-RPC, no HTTP, no extra connections:

```rust
use snapcast_proto::CustomMessage;

// Server pushes EQ to a specific client
cmd.send(ServerCommand::SendToClient {
    client_id: "kitchen".into(),
    message: CustomMessage::new(9, serde_json::to_vec(&EqConfig {
        bands: vec![Band { freq: 100, gain: 3.0 }, Band { freq: 10000, gain: -2.0 }],
    })?),
}).await;

// Client receives and applies
match event {
    ClientEvent::CustomMessage(msg) if msg.type_id == 9 => {
        let eq: EqConfig = serde_json::from_slice(&msg.payload)?;
        equalizer.update(eq);
    }
}
```

Message types 0–8 are reserved by the Snapcast protocol. Types 9+ are available for application use. The payload format is opaque — the library passes raw bytes, the application chooses JSON, bincode, protobuf, or any other format.

## Encryption (`--features encryption`)

Optional ChaCha20-Poly1305 authenticated encryption for f32lz4 audio chunks. Pure Rust (RustCrypto), zero C dependencies.

### Binary usage

The binaries support `f32lz4e` as a codec alias — it selects `f32lz4` with encryption using a built-in default key:

```bash
# Server — just works, default PSK
snapserver-rs --codec f32lz4e

# Client — just works, default PSK matches
snapclient-rs tcp://192.168.1.50:1704

# Custom PSK (must match on both sides)
snapserver-rs --codec f32lz4e --encryption-psk "my-secret"
snapclient-rs --encryption-psk "my-secret" tcp://192.168.1.50:1704
```

### Library usage

```rust
// Server
let config = ServerConfig {
    codec: "f32lz4".into(),
    encryption_psk: Some("my-secret-key".into()),
    ..ServerConfig::default()
};

// Client
let config = ClientConfig {
    encryption_psk: Some("my-secret-key".into()),
    ..ClientConfig::default()
};
```

- Key derivation: HKDF-SHA256 from PSK + random session salt
- Per-chunk: 12-byte nonce (counter) + 16-byte auth tag = 28 bytes overhead (~0.6%)
- Protects audio content and integrity — metadata (time sync, settings) stays plaintext
- Wrong key: client connects but audio chunks are silently dropped

## Documentation

API documentation: [snapcast-client](https://docs.rs/snapcast-client) · [snapcast-server](https://docs.rs/snapcast-server) · [snapcast-proto](https://docs.rs/snapcast-proto)

Generate locally: `cargo doc --open --no-deps`

## Building

```bash
cargo build --release                                    # default: flac + mdns
cargo build --release --features f32lz4                  # + f32lz4 (pure Rust)
cargo build --release --no-default-features --features f32lz4  # pure Rust, no C deps
```

## Usage

```bash
# Server
snapserver-rs --source "pipe:///tmp/snapfifo?name=Music"
snapserver-rs --codec flac
snapserver-rs --codec f32lz4e                            # encrypted f32lz4 (default key)
snapserver-rs --codec f32lz4e --encryption-psk "secret"  # custom key
snapserver-rs --help

# Client
snapclient-rs tcp://192.168.1.50:1704
snapclient-rs                                            # mDNS auto-discovery
snapclient-rs --encryption-psk "secret"                  # custom key
snapclient-rs --help

# Feed audio
ffmpeg -re -i music.mp3 -f s16le -ar 48000 -ac 2 pipe:1 > /tmp/snapfifo
```

## Code Quality

- `#![forbid(unsafe_code)]` on protocol crate
- `#![deny(unsafe_code)]` on client/server libraries
- Zero `#[allow(dead_code)]`, zero TODOs, zero `#![allow]` blankets
- Constant-time password comparison (subtle crate)
- Structured tracing logging

## Releases

Pre-built binaries for every release:

| Platform | Client | Server | FLAC |
|----------|--------|--------|------|
| Linux x86_64 ||||
| Linux aarch64 ||||
| macOS x86_64 ||||
| macOS aarch64 ||||
| Windows x86_64 ||||

Download from [GitHub Releases](https://github.com/metaneutrons/snapcast-rs/releases).

Library crates published to [crates.io](https://crates.io): `snapcast-proto`, `snapcast-client`, `snapcast-server`.

## License

GPL-3.0-only — same as the original Snapcast.