Skip to main content

lib/air_play.ex

defmodule AirPlay do
  @moduledoc """
  A pure-Elixir **AirPlay (RAOP) audio sender** — discover receivers on the LAN and
  stream lossless audio to them, with no native dependencies (just `:crypto`,
  `:gen_udp` and `:gen_tcp`).

  Targets classic AirPlay 1 / RAOP receivers (shairport-sync, AirPort Express, and
  — verified — Apple HomePods), streaming unencrypted ALAC over RTP with the
  NTP-style timing/sync the receiver requires.

  ## Quick start

      # Find receivers on the network (mDNS browse of _raop._tcp)
      AirPlay.discover()
      #=> [
      #=>   %{
      #=>     name: "Office",
      #=>     host: "172.16.42.35",
      #=>     port: 7000,
      #=>     model_identifier: "AudioAccessory5,1",
      #=>     txt: %{"am" => "AudioAccessory5,1"}
      #=>   }
      #=> ]

      # Stream a file (decoded via ffmpeg) at 40% volume
      {:ok, session} = AirPlay.play("172.16.42.35", "/music/track.flac", volume: 40)
      AirPlay.set_volume(session, 25)
      AirPlay.stop(session)

      # Or stream raw PCM you already have (44.1kHz, s16le, stereo interleaved)
      {:ok, session} = AirPlay.play_pcm("172.16.42.35", pcm, volume: 40)

  `play/3` requires `ffmpeg` on the PATH (used to decode the source file to PCM).
  `play_pcm/3` has no external dependency.

  Experimental AirPlay 2 building blocks (transient pairing, ChaCha20-Poly1305
  encrypted control channel, binary plist, SETUP, gPTP timing packet helpers)
  live under `AirPlay.V2`; the control plane is verified against real HomePods
  but a complete AP2 sender loop is not yet the default playback path, so use
  the AirPlay 1 API above for production playback.
  """

  alias AirPlay.{Cast, Discovery, Source}
  alias AirPlay.V2.Pairing

  @typedoc "A running playback session (the `AirPlay.Cast` GenServer pid)."
  @type session :: pid()

  @doc """
  Browse the LAN for AirPlay/RAOP receivers for `timeout_ms` (default 2500).

  Returns a list of receiver maps with `:name`, `:host`, and `:port`, plus any
  advertised mDNS TXT metadata such as `:txt`, `:model_identifier`,
  `:manufacturer`, `:source_version`, `:os_version`, and `:airplay_protocol`.
  """
  @spec discover(non_neg_integer()) :: [map()]
  defdelegate discover(timeout_ms \\ 2_500), to: Discovery, as: :receivers

  @doc """
  Decode the audio file at `path` (via `ffmpeg`) and stream it to `host`.

  Options:
    * `:volume` — 0–100 (default 25)
    * `:port` — RTSP port (default 7000)

  Returns `{:ok, session}` where `session` is a pid you pass to `set_volume/2`
  and `stop/1`; it streams in the background and stops itself when the track ends.
  """
  @spec play(String.t(), Path.t(), keyword()) :: {:ok, session()} | {:error, term()}
  def play(host, path, opts \\ []) do
    with {:ok, pcm} <- Source.pcm(path) do
      play_pcm(host, pcm, opts)
    end
  end

  @doc """
  Stream a raw PCM buffer (44.1 kHz, signed 16-bit little-endian, stereo,
  interleaved) to `host`. Same options as `play/3`.
  """
  @spec play_pcm(String.t(), binary(), keyword()) :: {:ok, session()} | {:error, term()}
  defdelegate play_pcm(host, pcm, opts \\ []), to: Cast, as: :play

  @doc """
  Stream the audio file at `path` to `host`, decoding *incrementally* via
  `ffmpeg` so memory stays bounded no matter how long the track is (a 4-hour
  audiobook would be ~2.4 GB if fully decoded up front, as `play/3` does).

  Options:
    * `:start_seconds` — seek offset before decoding (default 0)
    * `:volume` — 0–100 (default 25)
    * `:port` — RTSP port (default 7000)
    * `:ffmpeg` — ffmpeg binary path (defaults to one on `PATH`)

  Returns `{:ok, session}`. Requires `ffmpeg` (with the `-re` flag, used for
  native-rate flow control).
  """
  @spec play_file(String.t(), Path.t(), keyword()) :: {:ok, session()} | {:error, term()}
  defdelegate play_file(host, path, opts \\ []), to: Cast

  @doc "Set the playback volume (0–100) on a running `session`."
  @spec set_volume(session(), 0..100) :: :ok
  defdelegate set_volume(session, volume), to: Cast

  @doc "Stop a running `session` and tear down the RTSP connection."
  @spec stop(session()) :: :ok
  defdelegate stop(session), to: Cast

  @doc """
  Experimental AirPlay 2 transient pairing.

  This establishes the AP2 `/pair-setup` SRP exchange and upgrades the returned
  RTSP state to the encrypted control channel. It does not yet start audio; AP2
  playback still needs the PTP timing subsystem.
  """
  @spec pair_v2(String.t(), keyword()) :: {:ok, Pairing.t()} | {:error, term()}
  defdelegate pair_v2(host, opts \\ []), to: Pairing, as: :transient
end