Executive summary
StreamSpeaker routes macOS system audio to an Android TV over a local network. It ships as three components: a custom Core Audio HAL driver that installs as an Audio Server Plugin, a SwiftUI app that reads from the driver via shared memory and sends over UDP, and a Kotlin Android TV receiver that buffers and plays incoming audio with AudioTrack. An Android phone sender (using AudioPlaybackCapture) is also functional. The whole audio path — from HAL driver callback through shared memory through UDP to TV speakers — is verified end-to-end. The codebase is open-source on GitHub.
The problem
Android TVs sit in living rooms with capable speakers but no input path from a Mac. AirPlay is locked to Apple hardware on both ends. Bluetooth introduces compression, range limits, and codec negotiation. Casting protocols require cloud infrastructure or proprietary receivers. A Mac playing music at a desk has no clean way to push that audio to a TV across the room without buying into a walled-garden ecosystem.
The missing piece was a LAN-first, protocol-level solution: intercept system audio at the OS level, ship it over UDP, buffer it on the TV, and play it.
The thesis
The Core Audio HAL Audio Server Plugin API lets a kernel-level audio device run inside coreaudiod without app entitlements or user-space workarounds. If the driver only writes audio frames to a lock-free POSIX shared memory ring buffer — no network I/O, no malloc, no locking in the real-time callback — it can safely intercept any macOS system audio output. A companion app drains that buffer and sends 20 ms PCM packets over UDP fast enough that a 120 ms jitter buffer on the receiver hides any network jitter on a home Wi-Fi network.
The secondary bet: the same binary wire protocol (ATV1) works from an Android phone using the AudioPlaybackCapture API, so the receiver doesn't need to know or care which sender is upstream.
What I built
Core Audio HAL driver
- Custom Audio Server Plugin forked from Apple's NullAudio sample, implementing
AudioServerPlugIn_Hostcallbacks. - Creates a virtual output device that appears in System Settings → Sound → Output as "StreamSpeaker".
- Real-time-safe callback: writes Float32LE interleaved stereo to a POSIX
shm_openring buffer using C11 atomics — no malloc, no locks, no I/O. - Ring buffer capacity: 32,768 frames (~683 ms at 48 kHz), 266,240 bytes total shared memory.
- Install/uninstall scripts with
sudoandcoreaudiodrestart; prompts for reboot after install.
macOS SwiftUI app
NetServiceBrowser(Bonjour) discovers Android TV receivers advertising_streamspeaker._udpautomatically.- Manual IP:port entry for when mDNS fails on the router.
SharedMemoryAudioSourceC library (SharedAudioRing) bridges the POSIX ring buffer to Swift.- State machine: idle → connecting → streaming → stopping → error, with reconnect after TV restart.
- Diagnostics tab: live event log (5,000-event cap), real-time receiver buffer depth, packet stats, ring buffer cursor positions.
- Settings: transport mode (TCP/UDP), input source (sine wave for testing vs. shared memory for real audio), HAL driver install/uninstall via AppleScript-wrapped admin auth.
Android TV receiver
- Kotlin foreground service accepting both TCP and UDP connections.
TreeMap-based jitter buffer targeting 120 ms depth with sequence-aware insertion and late-packet discard.AudioTrackplayback at 48 kHz, S16LE stereo.- Android NSD registration as
StreamSpeaker: <Manufacturer> <Model>. - UDP telemetry back to sender every 250 ms:
bufferMs,bufferFrames,underruns,overruns,packetsReceived,packetsDropped,packetsLate. - MediaSession for home screen "Now Playing" card; stealth mode (OLED-friendly black screen); optional boot auto-start.
Android phone sender
AudioPlaybackCapture(Android 10+,MediaProjection) captures system-wide playback audio.- MTU-safe UDP: 7 ms packets, 336 frames, 1,376 bytes per datagram — fits in one Ethernet frame.
- Local speaker muted automatically during streaming; restored on disconnect.
- NSD discovery of receivers; manual IP fallback.
- Clean teardown: disconnect packet ×3, volume restore, resource release.
ATV1 wire protocol
Custom binary protocol with 32-byte fixed header:
| Field | Size | Notes |
|---|---|---|
| magic | 4 bytes | ASCII ATV1 |
| version | 1 byte | 1 |
| packet_type | 1 byte | hello / audio / telemetry / keepalive / disconnect / error |
| sequence | 8 bytes | monotonic counter |
| timestamp | 8 bytes | cumulative frame count at stream clock rate |
| payload_frames | 2 bytes | audio frames in packet |
| sample_format | 2 bytes | S16LE or Float32LE |
| payload_size | 4 bytes | payload bytes |
Default ports: TCP 49152, UDP audio 49153, UDP telemetry 49154.
Architecture
macOS apps (Spotify, browsers, VLC, etc.)
↓
AndroidTVSpeaker.driver (Core Audio HAL, runs inside coreaudiod)
↓
POSIX shm ring buffer (/com.macstreamer.audio-ring.v0, 266 KB, Float32LE)
↓
StreamSpeaker.app (SwiftUI, reads via SharedAudioRing C library)
↓
UDP audio port :49153 / TCP fallback :49152
↓
ReceiverService (Kotlin foreground service, Android TV)
↓
TreeMap jitter buffer (120 ms target)
↓
AudioTrack (48 kHz, S16LE stereo) → TV speakers
← UDP telemetry :49154 (buffer depth, underruns, packet loss) ←
Android phone path replaces the top three layers with an AudioPlaybackCapture foreground service sending 7 ms UDP packets.
Roadmap
- Long-session soak testing — 1+ hour continuous streams with drift correction verified over multiple runs.
- Clock drift handling — Mac-side pacing adjustment (±8 ms) based on receiver telemetry to prevent buffer creep or underrun.
- Signing and notarization — Developer ID signing for the HAL driver bundle and app, Gatekeeper-compatible distribution.
- Installer package —
.pkgwith privileged helper,coreaudiodrestart, and clean uninstaller. - Sleep/wake resilience — HAL driver reload and app reconnect after macOS sleep/wake cycle.
Current status
Functional end-to-end, not yet distributed. The core audio path works: Mac system audio routes through the HAL driver, shared memory, and UDP to play out of Android TV speakers. All three sender paths are verified. Active work is on soak testing, clock drift correction, and the distribution/signing pipeline. No installer yet — building and deploying requires running the build-app.sh script and adb install manually.