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 fixed PCM output format `{sample_rate, bits_per_sample, channels}`. For 24-bit/96 kHz stereo output, set `format: {96_000, 24, 2}`. ## 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 "Fixed PCM stream format `{rate, bits, channels}` sent to all clients." def format, do: config(:format, {48_000, 16, 2}) @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 "Default bitrate used when transcoding speech/long-form sources to Opus." def opus_bitrate, do: config(:opus_bitrate, "96k") @doc "FLAC compression level used by ffmpeg for Snapcast FLAC transport." def flac_compression_level, do: config(:flac_compression_level, 5) @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 "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`. """ 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 config(key, default), do: Application.get_env(:snapcast, key, default) end