AirplayKit

Swift, macOS and Linux

Play to the speaker in the other room without taking the whole machine with you.

Apple's route picker moves the system's output, so everything on the Mac follows it. AirplayKit speaks RAOP to the receiver itself. One application streams to the kitchen, and the rest of the machine keeps playing where it always did.

API reference Get the source

Finding the speakers
let speakers = AirPlayDiscovery { found in
    for speaker in found {
        // Dining Room
        print(speaker.name, speaker.host)
    }
}
  1. Your frames16 bit stereo, 44100 Hz
  2. The sessionpaired, encrypted, paced
  3. The networkencrypted RTSP and buffered audio
  4. The speakerits own volume, its own room

01

Discovery and playback

AirPlayDiscovery

Browses for AirPlay receivers and reports the current set each time it changes, sorted by name. It can find receivers without moving the system's output. Each AirPlayReceiver carries its speaker name, advertised model and connection details. The raw model can be abbreviated, such as Bookshelf; use productName for a formatted label or call await receiver.resolveProductName() when a fuller name is available from a standard device description.

Changes arrive on a queue you name. Typed AirplayKit events report appearances and advertised name, model and playback-state changes.

AirPlaySession

Pairs with one receiver and carries the audio. Writing never waits, because the thread producing live audio must not block, so a write that does not take the frames says whether the buffer is merely full or the receiver has hung up.

The volume it carries is the speaker's own control rather than a gain on the samples. The initial level is available when the receiver answers the volume query. Changes made at the receiver are reported while the session is open.

Playing to one of them
let session = try AirPlaySession(receiver: receiver, senderName: "My App")
if let level = session.volume { print(level) }
session.volume = 0.7

switch session.write(frames) {
case .taken:
    break
case .bufferFull:
    // A live source drops these frames; a file reader can pause and retry.
    break
case .ended:
    // Arrange to close the session outside the audio callback.
    break
}

02

Streaming a file

Read a file, turn it into what AirPlay carries, open a session, and keep it fed until the file runs out. On macOS AVFoundation converts formats the system can read. On Linux the portable example takes a WAVE file that is already 16 bit stereo at 44100 Hz.

Both functions are excerpts from Sources/Demo/Demo.swift and use its surrounding imports and helpers. To run the complete Demo, use swift run Demo file ~/Music/track.m4a speaker.local on macOS. On Linux, build first, grant the executable its PTP port capability as shown below, then run "$(swift build --show-bin-path)/Demo" wave ~/Music/track.wav speaker.local. On an Ubuntu 26.04 aarch64 guest, a WAVE file played audibly to one Sonos receiver. Linux group playback has not yet been checked with a listener.

/// Plays a file to a speaker, converting it to what AirPlay carries on the way.
func stream(_ file: AVAudioFile, to host: String, port: UInt16) throws {
    // Interleaved 16 bit stereo at 44100 is the only thing that goes over the wire.
    let audioFormat = AVAudioFormat(commonFormat: .pcmFormatInt16,
                                    sampleRate: Double(AirPlaySession.sampleRate),
                                    channels: AVAudioChannelCount(AirPlaySession.channelCount),
                                    interleaved: true)!
    let converter = AVAudioConverter(from: file.processingFormat, to: audioFormat)!

    let session = try AirPlaySession(host: host, port: port, senderName: "My App")
    session.volume = 0.2

    let framesPerChunk: AVAudioFrameCount = 4096
    let ratio = audioFormat.sampleRate / file.processingFormat.sampleRate
    let readBuffer = AVAudioPCMBuffer(pcmFormat: file.processingFormat, frameCapacity: framesPerChunk)!
    let convertedCapacity = AVAudioFrameCount(Double(framesPerChunk) * ratio) + 1
    let convertedBuffer = AVAudioPCMBuffer(pcmFormat: audioFormat, frameCapacity: convertedCapacity)!

    // Read against the file's own length rather than until it complains: asking
    // for one frame past the end throws rather than coming back empty.
    while file.framePosition < file.length {
        let remaining = file.length - file.framePosition
        try file.read(into: readBuffer, frameCount: AVAudioFrameCount(min(Int64(framesPerChunk), remaining)))

        var handedOver = false
        converter.convert(to: convertedBuffer, error: nil) { _, status in
            defer { handedOver = true }
            status.pointee = handedOver ? .noDataNow : .haveData

            return handedOver ? nil : readBuffer
        }

        let samples = convertedBuffer.int16ChannelData![0]
        let count = Int(convertedBuffer.frameLength) * AirPlaySession.channelCount

        // A file can wait, so it offers the same frames again. A live source would drop them.
        var outcome = session.write(UnsafeBufferPointer(start: samples, count: count))
        while outcome == .bufferFull {
            Thread.sleep(forTimeInterval: 0.01)
            outcome = session.write(UnsafeBufferPointer(start: samples, count: count))
        }

        if outcome == .ended { break }
    }

    // The sender still holds what has not gone out, so closing now would cut the end off.
    Thread.sleep(forTimeInterval: 4)
    session.close()
}

03

Groups and live changes

AirPlayGroup can start on one receiver and add others under a shared clock and timeline. Add and remove members while audio continues, dissolve the group, and set one receiver's volume or the whole group's. The average-volume control keeps relative levels where possible.

Typed AirPlayEvent values report discovery changes, volume changes on connected speakers and membership changes made through this sender. Another controller's group membership cannot be reliably inferred from the tested receivers' Bonjour records. Two and three Sonos speakers played together in local tests. A Sonos and HomePod mini pair also played together after Home speaker access was temporarily opened. The listener judged the speakers simultaneous; exact acoustic delay was not measured. Under the restored home-members-only rule, the HomePod rejects this library's fresh pairing.

Managing a group
let group = try AirPlayGroup(receivers: [office, diningRoom], senderName: "My App")
group.observeChanges { event in print(event) }
try group.add(bathroom)
try group.setVolume(0.5, for: office.id)
try group.remove(diningRoom.id)
group.dissolve()

Audio must arrive as interleaved signed 16 bit stereo at 44100 Hz. Resampling happens before writing to the library.

04

How to use it

It is a Swift package. The sender is written in Swift and uses swift-crypto and BigInt, which Swift Package Manager resolves during the build. C and Objective-C callers can use the separate CAirplayKit product.

// In Xcode: File > Add Package Dependencies, with this address.
// In a package of your own:

dependencies: [
    .package(url: "https://git.layered.work/phranck/AirplayKit.git", branch: "main"),
],
targets: [
    .target(name: "YourTarget", dependencies: ["AirplayKit"]),
]

Then import AirplayKit. The renamed product is available from main; a version tag has not yet been published. On Linux, use the same Package.swift dependency shown in the macOS tab. Browsing needs avahi-daemon and network access to mDNS advertisements. Playback needs permission to bind PTP UDP ports 319 and 320, and the receiver must be able to reach the sender on those ports. Reapply the capability after rebuilding the executable. setcap is provided by libcap2-bin.