Snapcast
A pure-Elixir Snapcast server: speak Snapcast's
binary protocol directly to snapclients, 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
def deps do
[{:snapcast, "~> 0.5.0"}]
end
Configure
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, client_buffer_ms, send_buffer_bytes, 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:
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.cppforcessample_size_ = 4for 24-bit):S24_LEin a 32-bit word. The client also scales samples asint32_tfor 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 packeds24le, 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 fixedchunk_mschunks. Maximum compatibility; uncompressed.:flac—ffmpegencodes the source to FLAC and the audio is split on whole-frame boundaries (validated by each frame's CRC-16 footer, so a sync code occurring inside the audio can't mis-split a frame), 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—ffmpegencodes 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 synthesisedopusCodecHeader. 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.
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
snapclientbuilt with the matching decoder (HAS_FLAC/HAS_OPUS). A client without it rejects the CodecHeader and reconnect-loops — use:pcmfor 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(default96_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(defaults1_000/4_000) — the end-to-end buffer scales with a stream's data rate, frombuffer_msfor a CD/48 k stream up tomax_buffer_msfor hi-res, so higher-bitrate streams get proportionally more jitter headroom. A standard stream is unchanged atbuffer_ms.client_buffer_ms(default%{}) — a per-endpoint minimum buffer, keyed by client id, e.g.%{"living-room" => 3_000}. A stream's effective buffer is the max of the format-scaled value above and the deepest floor among the clients it's playing to. Because a synchronized group must share onebufferMs(every client plays a chunk atchunk_ts + bufferMs), max-wins lets a flaky endpoint (e.g. Wi-Fi) run a deep buffer that the whole group inherits while grouped with it — and good endpoints still stream lean when playing alone.send_buffer_bytes(default1_048_576) — per-client socket send buffer. A transient link stall otherwise blocksgen_tcp.send, which freezes the session and starves that client's Time-sync replies → a resync pop even with a deep audio buffer. A large send buffer lets the stall queue in the kernel so the write returns immediately.0leaves the OS default.
Supervise
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
# Stream a file/URL to one or more connected clients (by their snapclient host id)
Snapcast.play("/music/track.flac", ["kitchen", "office"],
format: {44_100, 16, 2}, endpoint: {:group, ["kitchen", "office"]})
# Transport control is scoped to client ids — the stream(s) playing to them:
Snapcast.pause(["kitchen", "office"])
Snapcast.resume(["kitchen", "office"])
Snapcast.seek(30_000, ["kitchen", "office"])
Snapcast.stop_playback(["kitchen", "office"])
Snapcast.set_volume("kitchen", 40)
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.
Multiple simultaneous streams
A server can play any number of streams at once — one per client set. A client
plays at most one stream: starting a stream steals its target clients from the
stream that owns them. The previous stream keeps its remaining clients, or stops
silently when its last client is stolen (no ended listener event — the
redirect was caller-driven).
Snapcast.play("/music/latecoltrane.flac", ["office"]) # one stream
Snapcast.play("/podcasts/episode-12.mp3", ["kitchen"]) # a second, independent stream
Only a natural stream end (and the duration safety-net) emit ended/1;
progress/2 fires per stream with the :endpoint term that stream was started
with. A client stolen from a live stream has its buffered audio flushed (the
decoder is re-announced); an undisturbed group stays gapless.
Lifecycle events
Implement Snapcast.Listener to be told when clients connect/disconnect and how
playback is progressing:
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.