# Snapcast
A pure-Elixir **Snapcast server**: speak [Snapcast](https://github.com/badaix/snapcast)'s
binary protocol directly to `snapclient`s, owning the audio clock and timestamping every
chunk — so there is **no external snapserver** and no ffmpeg/snapserver pacing to fight.
The server stamps each `WireChunk` with the server-clock time at which it should play, and
each client plays it `bufferMs` later on its sync-corrected clock. Because the server (not
arrival order) assigns timestamps, there is no producer/consumer drift: the only requirement
is to send a chunk before its play deadline, and the `bufferMs` lead absorbs all jitter.
`ffmpeg` is required on the `PATH` (or configured) — it is used purely as a decoder/encoder
to turn sources into the wire format (raw PCM, or FLAC for the FLAC transport).
## Install
```elixir
def deps do
[{:snapcast, "~> 0.4.3"}]
end
```
## Configure
```elixir
config :snapcast,
enabled: true,
port: 1704,
bind_ip: {0, 0, 0, 0},
# default PCM output format: {sample_rate, bits_per_sample, channels}
format: {48_000, 16, 2},
# optional: receive lifecycle events
listener: MyApp.SnapcastListener
```
All settings are optional; see `Snapcast` for the full list and defaults
(`format`, `chunk_ms`, `buffer_ms`, `max_buffer_ms`, `max_sample_rate`,
`flac_compression_level`, `flac_frame_size`, `opus_bitrate`, `opus_frame_ms`, mDNS
advertising, a supervised local
`snapclient`, the `ffmpeg` path, …).
Snapcast decodes sources to the configured default PCM output format unless a
play call supplies a per-stream `:format`. To make the default 24-bit/96 kHz
stereo PCM, configure:
```elixir
config :snapcast,
format: {96_000, 24, 2}
```
Supported PCM bit depths are 16, 24, and 32 bits. The default remains
48 kHz/16-bit stereo for broad snapclient compatibility; use a higher default
or per-stream format only when the target clients and output chain support it.
> **24-bit framing.** Snapcast has no packed 3-byte PCM format — it carries
> 24-bit audio as a 4-byte sample (`sample_format.cpp` forces `sample_size_ = 4`
> for 24-bit): `S24_LE` in a 32-bit word. The client also scales samples as
> `int32_t` for software volume, so the 24-bit value is **sign-extended** into a
> valid little-endian int32 (low 3 bytes = sample, high byte = sign). ffmpeg
> decodes 24-bit to packed `s24le`, so each sample is widened before it goes on
> the wire. This is transparent — request `{_, 24, _}` and clients receive (and
> report) a genuine 24-bit stream.
Version `0.2.0` adds per-stream PCM formats. This lets an application play a
44.1 kHz file as `{44_100, 16, 2}` and a high-resolution file as its own
supported format instead of forcing every stream through the default format.
## Transports
The wire transport is chosen per `play/3` via `:transport_codec`:
- **`:pcm`** (default) — raw little-endian PCM, sliced into fixed `chunk_ms`
chunks. Maximum compatibility; uncompressed.
- **`:flac`** — `ffmpeg` encodes the source to FLAC and the audio is split on
whole-frame boundaries, one frame per timestamped chunk. **Lossless at roughly
half the wire rate** — useful for high-resolution music over constrained links.
FLAC is used only for 16/24-bit audio; 32-bit falls back to PCM automatically.
- **`:opus`** — `ffmpeg` encodes Opus in an Ogg container (forced to 48 kHz/16-bit/
stereo); the Ogg packets are extracted, one Opus packet per timestamped chunk, with a
synthesised `opus` CodecHeader. **Lossy, low-bitrate** — the right choice for already-
compressed sources (podcasts, audiobooks, radio) where re-encoding to FLAC would inflate
them for no quality gain.
```elixir
Snapcast.play("/music/hires.flac", ["living-room"],
format: {96_000, 24, 2},
transport_codec: :flac
)
Snapcast.play("/podcasts/episode.mp3", ["kitchen"], transport_codec: :opus)
```
FLAC compression effort is set with `flac_compression_level` (0–8, default `5`), and the
per-frame size with `flac_frame_size` (samples; default `1152`, ~26 ms). Each WireChunk is
one whole FLAC frame, so small frames keep chunks PCM-sized and low-latency — large frames
make each socket write hold up Time-sync replies and can make slower clients reconnect.
Opus bitrate is `opus_bitrate` (default `"96k"`) and frame size `opus_frame_ms`
(10/20/40/60 ms, default `20`).
> Both encoded transports require a `snapclient` built with the matching decoder
> (`HAS_FLAC` / `HAS_OPUS`). A client without it rejects the CodecHeader and reconnect-loops
> — use `:pcm` for such clients.
### Sample-rate cap & adaptive buffer
Because PCM is uncompressed (and 24-bit is carried in 32-bit words), very high
sample rates can outrun a client's link and cause dropouts. Two knobs guard
against this, both applied automatically per stream:
- **`max_sample_rate`** (default `96_000`) — a stream's sample rate is clamped to
this by **repeated halving** (192 k → 96 k, 176.4 k → 88.2 k), an exact 2:1
decimation rather than a fractional resample. Set higher to disable.
- **`buffer_ms` / `max_buffer_ms`** (defaults `1_000` / `4_000`) — the end-to-end
buffer scales with a stream's data rate, from `buffer_ms` for a CD/48 k stream
up to `max_buffer_ms` for hi-res, so higher-bitrate streams get proportionally
more jitter headroom. A standard stream is unchanged at `buffer_ms`.
## Supervise
```elixir
children = [
# ... your other children ...
] ++ Snapcast.children()
Supervisor.start_link(children, strategy: :one_for_one)
```
`Snapcast.children/0` returns the server subtree when `enabled: true`, otherwise `[]`.
## Play
```elixir
# Stream a file/URL to one or more connected clients (by their snapclient host id)
Snapcast.play("/music/track.flac", ["kitchen", "office"],
position_ms: 0,
format: {44_100, 16, 2}
)
Snapcast.pause()
Snapcast.resume()
Snapcast.seek(30_000)
Snapcast.set_volume("kitchen", 40)
Snapcast.stop_playback()
Snapcast.clients()
#=> [%{pid: #PID<...>, client_id: "kitchen", name: "Kitchen"}, ...]
```
A `source` may be a binary path/URL or a **0-arity function** returning one — the
function is called when the stream (re)starts, which is handy for short-lived signed
URLs that must be fetched fresh on each play/seek.
## Lifecycle events
Implement `Snapcast.Listener` to be told when clients connect/disconnect and how
playback is progressing:
```elixir
defmodule MyApp.SnapcastListener do
@behaviour Snapcast.Listener
@impl true
def clients_changed, do: MyApp.broadcast_endpoints()
@impl true
def progress(endpoint, position_ms), do: MyApp.Playback.progress(endpoint, position_ms)
@impl true
def ended(endpoint), do: MyApp.Playback.ended(endpoint)
end
```
The `endpoint` term is whatever you passed as `:endpoint` to `Snapcast.play/3` — it is
opaque to the server and echoed back unchanged.
## License
GPL-3.0-or-later. See [LICENSE](LICENSE).