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.
let speakers = AirPlayDiscovery { found in
for speaker in found {
// Dining Room
print(speaker.name, speaker.host)
}
}
- Your frames16 bit stereo, 44100 Hz
- The sessionpaired, encrypted, paced
- The networkencrypted RTSP and buffered audio
- 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.
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()
}
/// Plays a WAVE file to a speaker. Nothing here is Apple's, so it runs on Linux too.
func streamWave(at path: String, to host: String, port: UInt16, volume: Float = 0.2) throws {
let wave = try readWave(at: path)
// No resampling here: what the file holds has to be what AirPlay carries.
guard wave.sampleRate == AirPlaySession.sampleRate,
wave.channelCount == AirPlaySession.channelCount,
wave.bitsPerSample == 16 else {
print("this wants 16 bit stereo at \(AirPlaySession.sampleRate), and that file is "
+ "\(wave.bitsPerSample) bit, \(wave.channelCount) channel, at \(wave.sampleRate)")
throw POSIXError(.EINVAL)
}
let session = try AirPlaySession(host: host, port: port, senderName: "My App")
session.volume = volume
let reportedLevel = session.volume.map { String(describing: $0) } ?? "unknown"
print(" volume set to \(reportedLevel), told to the receiver as "
+ String(format: "%.1f dB", volume <= 0 ? -144 : Double(volume) * 30 - 30))
let samplesPerChunk = 4096 * AirPlaySession.channelCount
let bytesPerChunk = samplesPerChunk * MemoryLayout<Int16>.size
for start in stride(from: 0, to: wave.frames.count, by: bytesPerChunk) {
let chunk = wave.frames[start..<min(start + bytesPerChunk, wave.frames.count)]
var samples = [Int16](repeating: 0, count: chunk.count / MemoryLayout<Int16>.size)
_ = samples.withUnsafeMutableBytes { chunk.copyBytes(to: $0) }
// A file can wait, so it offers the same frames again. A live source would drop them.
var outcome = session.write(samples)
while outcome == .bufferFull {
Thread.sleep(forTimeInterval: 0.01)
outcome = session.write(samples)
}
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.
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"]),
]
# Compiler/linker tools and Avahi's Bonjour compatibility library.
sudo apt install build-essential libavahi-compat-libdnssd-dev libcap2-bin
swift build
# Allow the Demo to bind PTP UDP ports 319 and 320.
sudo setcap cap_net_bind_service=+ep "$(swift build --show-bin-path)/Demo"
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.