defmodule Snapcast do @moduledoc """ 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. Public entry points + the stream format. The server currently uses PCM as the compatibility transport while encoded Snap transport (opus/flac) is developed behind an explicit per-`play/3` `:transport_codec` option. ## Configuration Settings are read from the `:snapcast` application environment, e.g.: config :snapcast, enabled: true, port: 1704, bind_ip: {0, 0, 0, 0}, format: {48_000, 16, 2}, listener: MyApp.SnapcastListener `format` is the default PCM output format `{sample_rate, bits_per_sample, channels}`. `play/3` can override it with a per-stream `:format`. ## Supervision Add `Snapcast.children()` to your supervision tree; it returns the server subtree when `enabled: true`, or `[]` otherwise. ## Lifecycle events Configure a `Snapcast.Listener` (via `:listener`) to receive client connect/disconnect and playback progress/ended notifications. """ alias Snapcast.Server @doc "Default PCM stream format `{rate, bits, channels}`." def format do normalize_format(config(:format, {48_000, 16, 2})) || {48_000, 16, 2} end @doc """ Clamp a format's sample rate to `max_sample_rate/0` by repeated halving. Snapcast carries PCM uncompressed (24-bit is inflated to 32-bit on the wire), so a very high sample rate can exceed what a client link sustains and cause dropouts. Halving (192k→96k, 176.4k→88.2k) is an exact 2:1 decimation rather than a fractional resample; bit depth and channels are left untouched. """ def cap_format({rate, bits, channels}) when is_integer(rate), do: {cap_rate(rate), bits, channels} def cap_format(format), do: format defp cap_rate(rate) do max = max_sample_rate() if is_integer(max) and max > 0 and rate > max, do: cap_rate(div(rate, 2)), else: rate end @doc "Maximum Snapcast sample rate; higher-rate streams are halved down to it (see `cap_format/1`)." def max_sample_rate, do: config(:max_sample_rate, 96_000) @doc "Chunk duration in milliseconds." def chunk_ms, do: config(:chunk_ms, 20) @doc "End-to-end buffer (ms): clients play each chunk this long after its timestamp." def buffer_ms, do: config(:buffer_ms, 1000) @doc """ End-to-end buffer (ms) scaled to a stream's wire data rate. Higher-bitrate streams (hi-res PCM) leave thinner network headroom, so they get a proportionally deeper buffer to ride out jitter — scaled linearly from `buffer_ms/0` against a 48kHz/16-bit/stereo reference (192_000 wire bytes/s) and capped at `max_buffer_ms/0`. A standard CD/48k stream is unchanged at `buffer_ms/0`. """ def buffer_ms_for({rate, bits, channels}) when is_integer(rate) and is_integer(bits) and is_integer(channels) do base = buffer_ms() bytes_per_sec = rate * wire_sample_bytes(bits) * channels div(base * bytes_per_sec, 192_000) |> max(base) |> min(max_buffer_ms()) end def buffer_ms_for(_format), do: buffer_ms() @doc "Upper bound for the data-rate-scaled `buffer_ms_for/1`." def max_buffer_ms, do: config(:max_buffer_ms, 4_000) # Snapcast carries 24-bit samples in a 32-bit word on the wire (see `Stream.wire_pcm/2`). defp wire_sample_bytes(24), do: 4 defp wire_sample_bytes(bits) when bits in [16, 32], do: div(bits, 8) defp wire_sample_bytes(_bits), do: 2 @doc "Default bitrate used when transcoding speech/long-form sources to Opus." def opus_bitrate, do: config(:opus_bitrate, "96k") @doc """ Opus frame duration in milliseconds (sets the per-packet / WireChunk cadence). Must be a valid Opus frame size — one of 2.5, 5, 10, 20, 40, 60 ms (integer ms here, so 10/20/40/60). Small frames keep WireChunks tiny and latency low; 20ms is the default. """ def opus_frame_ms, do: config(:opus_frame_ms, 20) @doc "FLAC compression level used by ffmpeg for Snapcast FLAC transport." def flac_compression_level, do: config(:flac_compression_level, 5) @doc """ Whether ffmpeg reads its input at realtime speed (`-re`). Default `true`: ffmpeg is paced to 1× so the stream buffer stays bounded — without it a long source floods the buffer (the `buffer <> data` accumulation goes O(n²) and can stall the stream process). The trade-off is that frames are produced just-in-time, so an encode/scheduling hiccup can momentarily starve the pacer (more likely at high sample rates). Set `false` to let ffmpeg race ahead, which keeps the pacer reliably fed at the cost of an unbounded server-side buffer — use only for short sources. """ def realtime_input?, do: config(:realtime_input, true) @doc """ FLAC frame size (samples per frame) for the FLAC transport. Each WireChunk carries one whole FLAC frame, so the frame size sets the chunk cadence and end-to-end latency. Keep it small (snapcast's own server uses 1152, ~26ms at 48k) so chunks stay PCM-sized — large frames make each blocking socket write hold up the session's Time-sync replies, which can trip a client's sync timeout and make it reconnect. """ def flac_frame_size, do: config(:flac_frame_size, 1152) @doc "Normalize a transport codec name to `:pcm | :opus | :flac` (or `nil`)." def normalize_transport_codec(codec) when is_atom(codec) do codec |> Atom.to_string() |> normalize_transport_codec() end def normalize_transport_codec(codec) when is_binary(codec) do case codec |> String.trim() |> String.downcase() do "pcm" -> :pcm "opus" -> :opus "flac" -> :flac _other -> nil end end def normalize_transport_codec(_codec), do: nil @doc "Normalize a PCM format to `{sample_rate, bits_per_sample, channels}`." def normalize_format({rate, bits, channels}) do with rate when is_integer(rate) and rate > 0 <- integer(rate), bits when bits in [16, 24, 32] <- integer(bits), channels when is_integer(channels) and channels > 0 <- integer(channels) do {rate, bits, channels} else _invalid -> nil end end def normalize_format(format) when is_binary(format) do case String.split(format, ":", parts: 3) do [rate, bits, channels] -> normalize_format({rate, bits, channels}) _invalid -> nil end end def normalize_format(_format), do: nil @doc "TCP port to listen on (snapclients default to 1704)." def port, do: config(:port, 1704) @doc "TCP address to bind the server to." def bind_ip, do: config(:bind_ip, {0, 0, 0, 0}) @doc "Whether the server is started in the supervision tree." def enabled?, do: config(:enabled, false) @doc "The configured `Snapcast.Listener` module, if any." def listener, do: config(:listener, nil) @doc "Whether to supervise a local snapclient for this machine." def local_client_enabled?, do: config(:local_client, true) and is_binary(snapclient_path()) @doc "Path to the local snapclient executable, if available." def snapclient_path do case config(:snapclient_path, nil) do path when is_binary(path) and path != "" -> path _missing -> System.find_executable("snapclient") end end @doc "Stable host id for the supervised local snapclient." def local_client_id, do: config(:local_client_id, "snapcast-local") @doc "URL used by the supervised local snapclient." def local_client_url, do: config(:local_client_url, "tcp://127.0.0.1:#{port()}") @doc "snapclient log filter used by the supervised local client." def local_client_logfilter, do: config(:local_client_logfilter, "*:info") @doc "Whether to advertise the server over mDNS/DNS-SD." def advertise?, do: config(:advertise, true) @doc "mDNS service name advertised for the server." def advertise_name, do: config(:advertise_name, "Snapcast") @doc "Path to the DNS-SD publisher executable, if available." def dns_sd_path do case config(:dns_sd_path, nil) do path when is_binary(path) and path != "" -> path _missing -> System.find_executable("dns-sd") end end @doc "Path to the ffmpeg executable used to decode sources to PCM." def ffmpeg_path do config(:ffmpeg_path, nil) || System.find_executable("ffmpeg") || "/usr/bin/ffmpeg" end @doc """ Play a source to the given client ids. `source` is either a binary path/URL (decoded by ffmpeg) or a 0-arity function returning one, resolved lazily when the stream starts (e.g. for short-lived URLs). opts: `:position_ms`, `:endpoint` (opaque term echoed back in listener events), `:duration_ms`, `:transport_codec`, `:format`. """ defdelegate play(source, client_ids, opts \\ []), to: Server defdelegate pause(), to: Server defdelegate resume(), to: Server defdelegate seek(position_ms, playback_gen \\ nil), to: Server @doc "Stop the current stream." defdelegate stop_playback(), to: Server defdelegate set_volume(client_id, volume), to: Server @doc "List connected clients." defdelegate clients(), to: Server @doc """ The server's supervision subtree, gated by `enabled?/0`. Returns the `SessionSupervisor` + `Server` (plus the mDNS advertiser and a local snapclient when configured), or `[]` when not enabled. """ def children do if enabled?() do children = [ {DynamicSupervisor, name: Snapcast.SessionSupervisor, strategy: :one_for_one}, {Server, port: port()} ] children = if advertise?() do children ++ [Snapcast.Advertiser] else children end if local_client_enabled?() do children ++ [Snapcast.LocalClient] else children end else [] end end defp integer(value) when is_integer(value), do: value defp integer(value) when is_binary(value) do case Integer.parse(String.trim(value)) do {integer, ""} -> integer _invalid -> nil end end defp integer(_value), do: nil defp config(key, default), do: Application.get_env(:snapcast, key, default) end