by Akustikrausch (Andreas Wendorf)
a working, verified AirPlay 2 realtime-audio SENDER in c++. it pairs with a modern Apple TV 4K, a HomePod, or a macOS receiver, and streams clean, lossless ALAC to it over the encrypted RAOP/RTSP path apple actually uses today. bidirectional volume, seamless track changes, the works.
this is the part of the apple-tax that nobody published. you can find a hundred receivers. you can find python. you cannot find a small c++ thing that just sends AirPlay 2 realtime audio to a current apple device and keeps the session alive. so here it is, with the entire recipe written down.
🎧 this code ships in a real product: FXChainPlayer. a native windows audio player that casts straight to your apple tv / homepod / macbook over AirPlay 2. that's where this sender runs every day, against real hardware. go grab the player to hear it, or read on for the protocol.
the open AirPlay landscape is all receiver, wrong language, or stuck in 2014:
- shairport-sync is a receiver. brilliant, but the other direction.
- owntone (forked-daapd) is a whole media server, not a sender library. it can send, but you don't drop it into your app.
- pyatv is python, and a client/control library, not a c++ realtime audio pipe.
- AirConnect / raop_play / the old shairport "client" forks do AirPlay 1 / legacy rtsp. they do not do the AP2 handshake a 2024 apple tv demands (encrypted control channel, the event-channel + RECORD ordering, the hardcoded-ALAC realtime stream, the 30-second keep-alive).
apple never documented any of it. every byte here was recovered by reading the above as a spec (never copying a line), packet-watching, and a lot of "why did the socket just close after exactly one millisecond". the fact that it took this long is the whole argument for the repo existing.
if you only read one section, read this. AP2 realtime to a modern apple tv is seven things in the right order, and getting any one wrong gives you a session that looks connected and plays silence, or drops after ~30 s, or refuses at SETUP. in order:
-
pair, then an encrypted RTSP control channel, immediately. right after pair-verify, every RTSP request/response rides ChaCha20-Poly1305. frame =
[2-byte LE len][cipher][16-byte tag], chunk ≤ 1024 B, AAD = the length prefix, nonce =[4 zero bytes][8-byte LE counter], separate send/recv counters (Control-Write / Control-Read keys). skip this and the tv drops the socket ~1 ms after pair-verify. -
session/stream setup is the RTSP
SETUP rtsp://host/sessionIdMETHOD, notPOST /setup(that's a 404), and it's preceded by a requiredGET /info. -
open the event channel, send RECORD in the owntone order. tcp-connect to the
eventPortfrom the session SETUP, and send RECORD after the session SETUP / before the stream SETUP. without the event channel open you getRECORD=500/FLUSH=455. -
the audio key (
shk) = the first 32 bytes of the pairing shared secret, raw. no HKDF. used directly as the ChaCha20-Poly1305 key for the audio payload and sent verbatim in the stream-SETUP plist. (see the transient note below; this is where macOS bit us.) -
ALAC is mandatory on the realtime stream. the receiver hardcodes ALAC and ignores
ct/audioFormat. send uncompressed ALAC frames (audioFormat 0x40000,ct 2, type0x60): MSB-first3b stereo-CPE(=1) · 4b 0 · 12b 0 · 1b hasSize=0 · 2b 0 · 1b isNotCompressed=1 · 352×{L16,R16} · 3b END(=7) · byte-align. -
the keep-alive IS the encrypted event channel. after RECORD the tv tears the whole session down at ~25-30 s unless you decrypt the receiver's pushed
POST /command(updateInfo) events and answer200 OK. the events keys areHKDF "Events-Salt" / "Events-Write|Read-Encryption-Key"over the pair-verify secret, swapped (reverse connection → eventIn decrypts with the WRITE key, eventOut encrypts with the READ key).POST /feedbackmust beRTSP/1.0(not HTTP/1.1), but feedback alone is not the keep-alive, the event channel is. -
the 200-OK must be minimal.
RTSP/1.0 200 OK\r\nServer: …\r\n[CSeq]\r\n\r\n, and nothing else. addingAudio-Latency: 0orContent-Length: 0corrupts the receiver's realtime timeline → the session stays connected and renders silence. this was the final "stable but silent" bug: timing, sync and ALAC were all correct, the two extra response headers were the whole fault.
the audio key in step 4 is the first 32 bytes of the pairing shared secret. that secret comes out at different lengths depending on how you paired:
- pair-verify (Apple TV, on-screen PIN,
sf=0x644): X25519 ECDH = 32 bytes. use the whole thing. - HAP transient (MacBook / HomePod,
sf=0x4): pairing stops at pair-setup M4 (no pair-verify), and the secret is the SRP session keyK = SHA-512(S) = 64 bytes.
feed the full 64-byte K into a ChaCha key and it throws chacha key size
every audio packet → zero audio sent → the receiver drops the otherwise
healthy session after its ~30 s no-audio timeout (the cover art + control
channel still work, because those keys are HKDF over the full K, which is
length-independent). clamp the audio key to the first 32 bytes. owntone's
airplay.c (AIRPLAY_AUDIO_KEY_LEN = 32) says it outright: "for transient
pairing the key_len will be 64 bytes, but only 32 are used for audio payload
encryption." the pair-verify secret is already 32, so the clamp is a no-op there.
that one line is the difference between "macbook shows the cover and is silent"
and "macbook plays".
src/
airplay_crypto.h / .cpp the qt-free crypto + wire-format core
raop_sender.h / .cpp the AP2 sender state machine (the recipe, in code), sans-i/o
raop_io.h the six-method seam the sender sends through
raop_loop.h / .cpp the bundled poll()/WSAPoll() host: sockets + the run loop
raop_qt_host.h / .cpp optional: the same host on Qt sockets (off by default)
raop_auth.h how a receiver wants to be authenticated (the Auth enum)
raop_creds.h the stored-pairing credentials blob (json, no qt)
raop_log.h the log sink + a tiny "{}" formatter
ring_buffer.h the lock-free spsc tap the audio thread feeds
example/airplay_send.cpp the cli demo: pair, set up, stream a wav
test/core_tests.cpp the protocol core against an in-process fake receiver
test/loop_tests.cpp the poll host against a real loopback receiver
third_party/ed25519/ the one primitive mbed tls lacks (zlib, vendored)
airplay_crypto is the genuinely reusable, Qt-free core
(std::vector<uint8_t> + std::string only): SRP-6a-3072 / SHA-512, X25519
ECDH, Ed25519 sign/verify, ChaCha20-Poly1305 AEAD, HKDF-SHA512, HomeKit TLV8,
and a minimal bplist00 encoder/decoder, exactly the pieces AP2 pairing +
the encrypted channels need, and nothing else. backed by Mbed TLS 3.6
(Apache-2.0) + orlp's ed25519 (zlib). drop it in.
raop_sender is the state machine that is the recipe above: pairing,
the encrypted control channel, the event channel, the ALAC realtime encoder,
the keep-alive. it is sans-i/o: it owns no socket, no timer and no thread.
it sends through RaopIo (six methods: tcp connect / send / close, udp bind /
send / close) and the host pushes back in what happened (onTcpData,
onUdpDatagram, tick). that is what makes it a drop-in: RaopLoop is the
bundled portable host, RaopQtHost the optional one for an app that already
runs Qt, and a test can drive the whole handshake with a fake host and no
network at all.
you need cmake 3.16+ and a c++20 compiler. the only c++20 library feature the
code uses is std::span, so the floor is low: gcc 10 and clang 14 build
the tree and pass the suite, and ci additionally runs current gcc, clang, apple
clang and msvc 2022. mbed tls is fetched at configure time (network needed
once); ed25519 is vendored. nothing else.
cmake -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build --parallel
ctest --test-dir build --output-on-failure
that builds airplay_crypto, raop_sender (core + host), the airplay_send
demo and the two test binaries.
| cmake option | default | what it does |
|---|---|---|
AIRPLAY_BUILD_EXAMPLE |
ON | the airplay_send cli demo |
AIRPLAY_BUILD_TESTS |
ON | the two test binaries, registered with ctest |
AIRPLAY_BUILD_QT_HOST |
OFF | RaopQtHost, needs Qt6 Core + Network |
AIRPLAY_SANITIZE |
OFF | address + undefined sanitizers (own code only) |
to embed the library, link raop_sender (it pulls in airplay_crypto,
mbedcrypto and ed25519); -DAIRPLAY_BUILD_EXAMPLE=OFF -DAIRPLAY_BUILD_TESTS=OFF
keeps the build to just that.
./build/airplay_send <receiver-ip> [file.wav] [--atv | --mac | --ap1] [--name TEXT] [--volume 0..100] [--strict]
--atv(default): apple tv, hap pairing with the on-screen pin. the pin is asked once; the long-term credentials are saved next to the binary (airplay_creds_<ip>.json) and later runs skip it.--mac/--homepod: hap transient pairing, no pin.--ap1: airplay 1, plain rtsp (shairport-sync, apple tv 3, raop speakers).- no file: a 440 Hz test tone. a wav needs 16-bit pcm, mono or stereo, any rate.
- ctrl-c sends TEARDOWN and exits.
the receiver's ip comes from mdns (dns-sd -B _raop._tcp on a mac,
avahi-browse _raop._tcp on linux, or the receiver's network settings); a
bundled browser is still on the roadmap.
the receiver does not only answer on the tcp control channel: it sends
unsolicited inbound udp to the sender's timing and control ports (the
ntp-style clock sync that the ap2 session SETUP waits for, later the
retransmit requests). windows firewall drops unsolicited inbound udp by
default, so the handshake stalls at the session SETUP and times out. allow
inbound udp for the program (a per-program rule is enough). a nat'd vm fails
the same way; use bridged networking. airplay_send prints this hint on a
handshake timeout. (diagnosed on a windows vm by @rursache in #1, thanks.)
RaopLoop loop; // or RaopQtHost inside a Qt app
RaopSender sender(loop, events, log); // events: launched / closed / pinRequired / credentialsObtained
sender.attachRing(&ring); // your audio thread feeds the ring (16-bit stereo, any rate)
sender.setInputFormat(48000);
sender.setIdentity({"my player", "AA:BB:CC:DD:EE:FF", "iPhone14,3"});
sender.setAuth(RaopDeviceInfo::Auth::HapPin, /*airplay2=*/true, deviceId, storedCredsJson, "");
sender.start(ip, 7000, "Living Room");
loop.run(sender); // until loop.requestStop(); sender.stop() sends TEARDOWNthis is lifted, working, and verified out of FXChainPlayer, where it
casts to a real Apple TV 4K (AppleTV14,1) and a MacBook every day. roadmap
m1..m3 have landed: the sender is a standalone, Qt-free library. the
state machine is sans-i/o, the bundled RaopLoop drives it on linux, macos and
windows, airplay_send is the runnable proof, and the test suite walks the
complete ap1 and ap2 handshakes (pair-setup, pair-verify, the encrypted control
channel, the event channel, encrypted alac audio) against an in-process fake
receiver, byte for byte.
honest caveat: the wire bytes are the verified Qt build's and the tests pin
them, but the Qt-free build itself has not had its fresh listen against the
apple tv / macbook yet. that is the first item in ROADMAP.md. still open
after that: a bundled mdns browser (you pass the receiver's ip today) and
fail-closed receiver auth by default (available as an option, see security).
if you want the polished player it lives in, here:
→ https://github.com/akustikrausch/FXChainPlayer-Releases
this is interoperability research, not an audited production security stack. one
thing worth owning up front: by default the sender does not cryptographically
authenticate the receiver. the pair-verify signature and the SRP proof are
checked, but a mismatch is logged and the session continues, so a same-LAN
man-in-the-middle could in principle accept your session and you'd stream to it
(you'd leak the audio + the transient session key, not take attacker data into a
trust boundary, it's a sender).
RaopSender::setStrictReceiverAuth(true) (airplay_send --strict) makes both
checks fail closed: a wrong or missing proof / signature aborts the session
before any setup traffic. it is opt-in until it has been confirmed against real
receivers; making it the default is on the roadmap.
what is defended either way: the untrusted-input parsers (bplist / TLV8 / RTSP / the encrypted event frames) are bounds-checked against OOB + alloc-DoS, and the AEAD usage is authenticate-before-use with per-channel keys + counters.
bottom line: use it on a network you trust, and turn strict mode on if you
don't. report anything via SECURITY.md.
Apache-2.0 for everything in src/. © 2026 Andreas Wendorf (Akustikrausch).
apache-2.0 on purpose: this is reverse-engineered apple-protocol code, so the
license carries an explicit patent grant, so you can embed it in a product
without the "is this safe to ship" patent worry that keeps MIT-licensed protocol
code out of corporate codebases. fully permissive, no copyleft; keep the NOTICE.
provenance, split honestly:
- the crypto + wire-format core (
airplay_crypto.*) is clean-room, reconstructed by reading owntone / pyatv / shairport-sync / pair_ap / emanuelecozzi's AP2 notes / the unofficial spec as documentation only. no upstream code is copied into it; only the on-the-wire byte formats. - the RAOP / AirPlay transport in
raop_sender.cppis in part a C++ port of pyatv (MIT, © 2020 Pierre Ståhl), its RTSP/RTP/sync/timing model + the HAP pairing sequence follow pyatv's modules. no python is bundled; pyatv's MIT notice rides along inlicenses/THIRD-PARTY-NOTICES.txt.
vendored / build deps keep their own licenses: Mbed TLS Apache-2.0, ed25519 zlib, same file has the details.
not affiliated with, authorized by, or endorsed by Apple Inc. AirPlay, Apple TV, HomePod, HomeKit and macOS are trademarks of Apple Inc., used here only to describe what this code talks to. nothing here ships an apple key, certificate, or any extracted firmware; it's a clean-room client of a network protocol, for interoperability with your own devices. use it on hardware you own and are allowed to use.
this is interoperability work in the legal sense: it relies on the decompilation / interoperability right under article 6 of eu directive 2009/24/ec (the software directive), reimplements the protocol clean-room, and ships none of apple's code, keys, or certificates.