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

## Install

```elixir
def deps do
  [{:snapcast, "~> 0.2.0"}]
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`, 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.

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