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.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:

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:

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.