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 to turn sources into raw PCM.

Install

def deps do
[{:snapcast, "~> 0.2.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, 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.

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