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 "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 "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