Skip to main content

README.md

# 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.2"}]
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).