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.4.4"}]
endConfigure
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.SnapcastListenerAll 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:
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.
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"],
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:
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)
endThe 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.