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

def deps do
[{:airplay, "~> 0.5.1"}]
end

AirPlay 1 / RAOP usage

# 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:

%{
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.

# 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:

# 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.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/GroupPlayerplay_file/3) and they are advertised on every RTSP request, telling the receiver where to send those commands:

{: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:

OptionDefaultWhat it does
:prebuffer_frames125 (~1s)buffered before the first (cold) packet on a session
:warm_prebuffer_frames16 (~128ms)buffered before a track change on an already-streaming Session/GroupSession — kept small so albums stay ~gapless
:render_delay_ms200how far ahead audio is scheduled for the receiver to buffer
:ptp_settle_ms500wait after BMCA before streaming the first track
:ptp_sync_timeout_ms3_000max 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

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 beforeSETUP (HomePods return 520 Origin Error otherwise), then paces ALAC RTP packets to the receiver against a wall-clock with periodic sync packets.

ModuleRole
AirPlay.DiscoverymDNS _raop._tcp browse
AirPlay.Rtsp / AirPlay.SessionRTSP control plane + handshake
AirPlay.PlayerRTP audio streaming + timing/sync
AirPlay.Rtp / AirPlay.Alac / AirPlay.Ntppacket builders + codecs
AirPlay.Sourcefile → PCM (ffmpeg) + framing
AirPlay.Castplay/stop/volume session GenServer
AirPlay.V2.Pairing / AirPlay.V2.Srp / AirPlay.V2.SecureChannelAirPlay 2 transient pairing + encrypted RTSP
AirPlay.V2.Setup / AirPlay.V2.Rtsp2 / AirPlay.V2.PlistAirPlay 2 control plane
AirPlay.V2.PtpBmca / AirPlay.V2.PtpAirPlay 2 PTP timing
AirPlay.V2.Player / AirPlay.V2.GroupPlayerAirPlay 2 one-shot single-device and group playback
AirPlay.V2.Session / AirPlay.V2.GroupSessionAirPlay 2 persistent playback — connection/clock reuse across tracks
AirPlay.Decoderstreaming ffmpeg decode (bounded-memory, on-demand frames)

License

GNU General Public License v3.0 or later (GPL-3.0-or-later). See LICENSE.