# AirPlay
A pure-Elixir **AirPlay audio sender**. Discover receivers on your network and
stream lossless audio to them using Erlang/OTP primitives: `:crypto`, `:gen_udp`
and `:gen_tcp`.
It supports classic **AirPlay 1 / RAOP**: unencrypted ALAC over RTP with the
NTP-style timing/sync receivers require. Verified streaming real audio to
shairport-sync, AirPort Express, and Apple **HomePods**.
It also has working **AirPlay 2** support under `AirPlay.V2`: transient SRP
pairing, ChaCha20-Poly1305 encrypted RTSP, binary plist SETUP/RECORD/FLUSH,
PTP timing, encrypted ALAC RTP audio, volume control, periodic `/feedback`
keepalives, multi-room AirPlay 2 group playback, and optional
[DACP](#on-device-remote-control-dacp) identity headers so the receiver can
control the source on-device (stop/pause/next/volume from the speaker itself).
Audio is **stream-decoded** (ffmpeg `-re` → on-demand frames, audio stream only —
embedded cover art is dropped), so playback starts after a short prebuffer instead
of decoding the whole file up front, and a multi-hour source no longer holds its
entire PCM in memory.
AirPlay 2 timing uses the receiver's own PTP clock. A HomePod's grandmaster clock
runs on its uptime (nowhere near host wall-clock time) and it does not answer
`Delay_Req`, so the offset is taken **one-way** from each `Sync`/`Follow_Up` — RTP
sync packets are then stamped in the receiver's clock frame rather than the host's,
which is what makes playback work regardless of the host OS or clock.
AirPlay 2 playback also comes in two flavours: one-shot `AirPlay.V2.Player` /
`AirPlay.V2.GroupPlayer` (pair → set up → stream one file → tear down), and
**persistent `AirPlay.V2.Session` / `AirPlay.V2.GroupSession`** that keep the
connection and PTP clock warm across tracks — so an album or audiobook changes
tracks without re-paying the ~5s pair + SETUP + PTP cold-start each time.
This is still WIP, created to scratch an itch.
## Installation
```elixir
def deps do
[{:airplay, "~> 0.5.1"}]
end
```
## AirPlay 1 / RAOP usage
```elixir
# Discover receivers (mDNS browse of _raop._tcp.local)
AirPlay.discover()
#=> [%{name: "Office", host: "172.16.42.35", port: 7000}, ...]
# Stream a file (decoded to PCM 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.1 kHz, signed 16-bit LE, stereo, interleaved)
{:ok, session} = AirPlay.play_pcm("172.16.42.35", pcm, volume: 40)
```
A `session` is a lightweight GenServer that streams in the background and stops
itself when the track ends; pass it to `set_volume/2` and `stop/1`.
### Discovery metadata
`AirPlay.discover/1` returns the receiver's address fields and preserves advertised
mDNS TXT metadata when it is present:
```elixir
%{
name: "Office",
host: "172.16.42.35",
port: 7000,
target: "Office.local",
device_id: "06091FB8DC4F",
model_identifier: "AudioAccessory5,1",
manufacturer: "Apple",
features: "0x4A7FCA00,0x3C354BD0",
source_version: "950.7.1",
os_version: "26.5",
protocol_version: "65537",
firmware_version: "p20.4.6.333053",
advertised_services: ["raop"],
airplay_protocol: "airplay1",
txt: %{"am" => "AudioAccessory5,1", "ov" => "26.5", "vs" => "950.7.1"}
}
```
Not every receiver advertises every field. The raw TXT map is kept so callers can
decode additional vendor-specific capability flags without losing data.
## AirPlay 2 usage
The AirPlay 2 API is lower-level than the RAOP `AirPlay.play/3` convenience API
and currently lives under `AirPlay.V2`.
```elixir
# Play one file to an AirPlay 2 receiver. This call runs until playback finishes.
{:ok, stats} =
AirPlay.V2.Player.play_file("172.16.42.35", "/music/track.flac",
volume: 0.4
)
# Play the same file to an AirPlay 2 receiver group.
{:ok, stats} =
AirPlay.V2.GroupPlayer.play_file(
[
%{host: "172.16.42.35", port: 7000},
%{host: "172.16.42.62", port: 7000}
],
"/music/track.flac",
volume: 0.4
)
```
For long-running apps, start AirPlay 2 playback in a supervised process or task.
`AirPlay.V2.Player.set_volume/2`, `AirPlay.V2.GroupPlayer.set_volume/2`,
`AirPlay.V2.Player.stop/1`, and `AirPlay.V2.GroupPlayer.stop/1` operate on that
running playback process.
### Persistent sessions (connection reuse across tracks)
`play_file/3` pairs, sets up, runs PTP, streams one file, and tears everything
down — fine for a single track, but paying that ~5s cold-start on *every* track
of an album is wasteful. `AirPlay.V2.Session` (and `AirPlay.V2.GroupSession` for
groups) keep the connection and clock warm so the next track just flushes and
streams:
```elixir
# Connect once (pair → SETUP → RECORD → PTP). Returns a session process.
{:ok, session} = AirPlay.V2.Session.connect("172.16.42.35", owner: self())
# Stream successive files down the same connection. Each call continues the RTP
# timeline (FLUSH with the running seq/timestamp), so there is no re-pair / re-PTP.
AirPlay.V2.Session.play(session, "/music/track-1.flac")
# ... when the track ends the session sends `{AirPlay.V2.Session, :ended, gen}`
# to `owner` and goes idle, keeping the connection alive with FEEDBACK keepalives.
AirPlay.V2.Session.play(session, "/music/track-2.flac")
AirPlay.V2.Session.set_volume(session, 0.4)
AirPlay.V2.Session.stop(session) # flush current track, stay connected
AirPlay.V2.Session.close(session) # tear down and release the receiver
```
The session sends its `owner` (defaulting to the caller of `connect/2`):
- `{AirPlay.V2.Session, :ended, play_gen}` — the current track finished cleanly
- `{AirPlay.V2.Session, :error, play_gen, reason}` — a track failed to start; the
session stays connected and idle
`AirPlay.V2.GroupSession` has the same API but takes a list of receivers in
`connect/2` (the first receiver is the PTP primary that drives the shared clock).
### On-device remote control (DACP)
When you press play/pause/next or change volume **on the receiver itself** (a
HomePod, an Apple TV, or the iOS Control Center route), the receiver doesn't act
locally — AirPlay is asymmetric and the speaker has no transport state of its own.
It instead sends a **DACP** (Digital Audio Control Protocol) command back to the
*source* over HTTP, authenticated with the `Active-Remote` token the source put
in its RTSP session.
Pass `:dacp_id` and `:active_remote` to `connect/2` (or the `Player`/`GroupPlayer`
`play_file/3`) and they are advertised on every RTSP request, telling the receiver
where to send those commands:
```elixir
{:ok, session} =
AirPlay.V2.Session.connect("172.16.42.35",
owner: self(),
dacp_id: "DE00F37DED4986B3", # 64-bit, upper-case hex
active_remote: "2426633712" # per-source token the receiver echoes back
)
```
Omit the opts and the headers are not sent — behaviour is unchanged.
**Receiving the commands is the caller's responsibility.** This library only
advertises the identity; to act on it you must, on a port you control:
1. advertise a Bonjour `_dacp._tcp` service named `iTunes_<DACP-ID>`, and
2. run an HTTP server that authenticates the `Active-Remote` header and handles
`GET /ctrl-int/1/<command>` — e.g. `play`, `pause`, `playpause`, `stop`,
`nextitem`, `previtem`, `volumeup`, `volumedown`, and
`setproperty?dmcp.device-volume=<dB>` (slider, roughly `-30..0`).
### Tuning playback reliability
The AirPlay 2 players/sessions take a few timing options (passed to `play_file/3`
or `connect/2`). The defaults below are chosen for reliable first-frame playback
across hosts — a near-empty buffer starves the receiver at the start of a stream,
and on some hosts (e.g. FreeBSD) it never recovers without a seek:
| Option | Default | What it does |
| --- | --- | --- |
| `:prebuffer_frames` | `125` (~1s) | buffered before the first (cold) packet on a session |
| `:warm_prebuffer_frames` | `16` (~128ms) | buffered before a track change on an already-streaming `Session`/`GroupSession` — kept small so albums stay ~gapless |
| `:render_delay_ms` | `200` | how far ahead audio is scheduled for the receiver to buffer |
| `:ptp_settle_ms` | `500` | wait after BMCA before streaming the first track |
| `:ptp_sync_timeout_ms` | `3_000` | max wait for the PTP one-way offset to converge before streaming |
The large cold prebuffer only applies to the first stream on a connection; once a
`Session`/`GroupSession` is warm, track changes use `:warm_prebuffer_frames`. Lower
it for snappier/gapless track changes if your host never starves; raise it (or
`:prebuffer_frames` / `:render_delay_ms`) if audio drops out at a track start.
## Requirements
- Elixir ~> 1.15
- `ffmpeg` on the `PATH` — **only** for `AirPlay.play/3` (file decoding).
`AirPlay.play_pcm/3` has no external dependency.
## How it works
`play/3` opens an RTSP control connection (`OPTIONS → ANNOUNCE → SETUP → RECORD`),
binds the UDP timing/control ports and answers the receiver's NTP timing probe
*before* `SETUP` (HomePods return `520 Origin Error` otherwise), then paces ALAC
RTP packets to the receiver against a wall-clock with periodic sync packets.
| Module | Role |
| --- | --- |
| `AirPlay.Discovery` | mDNS `_raop._tcp` browse |
| `AirPlay.Rtsp` / `AirPlay.Session` | RTSP control plane + handshake |
| `AirPlay.Player` | RTP audio streaming + timing/sync |
| `AirPlay.Rtp` / `AirPlay.Alac` / `AirPlay.Ntp` | packet builders + codecs |
| `AirPlay.Source` | file → PCM (ffmpeg) + framing |
| `AirPlay.Cast` | play/stop/volume session GenServer |
| `AirPlay.V2.Pairing` / `AirPlay.V2.Srp` / `AirPlay.V2.SecureChannel` | AirPlay 2 transient pairing + encrypted RTSP |
| `AirPlay.V2.Setup` / `AirPlay.V2.Rtsp2` / `AirPlay.V2.Plist` | AirPlay 2 control plane |
| `AirPlay.V2.PtpBmca` / `AirPlay.V2.Ptp` | AirPlay 2 PTP timing |
| `AirPlay.V2.Player` / `AirPlay.V2.GroupPlayer` | AirPlay 2 one-shot single-device and group playback |
| `AirPlay.V2.Session` / `AirPlay.V2.GroupSession` | AirPlay 2 persistent playback — connection/clock reuse across tracks |
| `AirPlay.Decoder` | streaming ffmpeg decode (bounded-memory, on-demand frames) |
## License
GNU General Public License v3.0 or later (`GPL-3.0-or-later`). See
[LICENSE](LICENSE).