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