Why not Bluetooth
macOS implements the Bluetooth A2DP source role and has no sink. Your Mac can send audio to headphones but cannot receive it from anything, so it can never appear in a phone's Bluetooth output list as a speaker. No setting changes this, on any macOS version.
The audio therefore travels over ADB instead, by cable or over your own network, and arrives at the Mac as an ordinary stream that CoreAudio can play.
The whole thing is one command
scrcpy --no-video --no-window --no-control \
--audio-source=playback --audio-buffer=N --audio-output-buffer=5
Three details carry most of the weight.
Why --audio-source=playback
It uses Android's AudioPlaybackCapture, which taps each app's PCM
before stream volume is applied. That is why the phone's volume slider is
irrelevant and can sit at zero.
playback
Pre-volume. Apps can opt out: Instagram does not, Spotify does.
output
Uses REMOTE_SUBMIX. Post-volume, so it records silence whenever the
phone's media stream is muted. Measured at 87 dB below the playback source under
exactly that condition.
Why --no-window
Without it, SDL initialises and registers scrcpy as a GUI application, so it appears in the Dock and the app switcher despite having nothing to display. Audio playback still works with the flag set, which was verified by observing CoreAudio frames in scrcpy's stacks rather than assumed.
Why there is no intermediate player
An earlier design piped Opus into mpv --audio-device=<UID>. mpv can
pin an output device by identifier and refuses to fall back when it disappears,
which made a hard guarantee possible: audio could never reach the speakers.
It was dropped for latency. mpv's --audio-buffer defaults to 0.2 s, which
went unnoticed and cost 200 ms. Even correctly tuned, the extra encode, mux, pipe, demux
and decode stages cost about 10 ms. The guarantee was traded for that, deliberately.
The output watchdog
scrcpy plays to the default output and cannot be told to use a specific device. So the guard is a watchdog, not a pin:
- On start, the chosen device is made the macOS default output. If that fails, the bridge refuses to start rather than playing somewhere unintended.
- While running, the default output is polled twice a second.
- If it stops being the chosen device, scrcpy's process group is killed.
Exposure is up to about half a second, not zero. If your headphones disconnect mid-stream, macOS reassigns default output to the built-in speakers, and audio can reach them before the watchdog fires. Turning the toggle off removes even that check.
Setting the default device is verified by reading the property back rather than
trusting the return code, because CoreAudio returns noErr for devices that
then silently refuse to become default. At least one widely used tool reports success in
exactly that case.
Presence is judged by CoreAudio, never Bluetooth
AirPods Max can be Bluetooth-connected yet completely absent from CoreAudio when idle in standby. A Bluetooth-based check would report them as present, start the bridge, and fail. CoreAudio enumeration is the only signal that matches reality.
Architecture
All bridge logic lives in bin/pab, a shell script that works standalone.
The app is a supervisor and a status display over it, and a small CoreAudio helper reads
and sets the default output device fast enough to poll twice a second.
| Piece | Responsibility |
|---|---|
bin/pab | Device selection, transport, watchdog, process lifecycle |
paboutput | Reads and sets the macOS default output device, in under 50 ms |
| The app | State, window, menu bar, and tearing everything down on quit |
How every number was measured
| Quantity | Value | Method |
|---|---|---|
| Mac to AirPods Max, Bluetooth | 170.7 ms | CoreAudio reported device latency |
| Wi-Fi jitter to the phone | 6 to 21 ms, sigma 3.6 | 20 ping packets |
| Wi-Fi buffer floor | 200 ms | 50 ms gave 4 sample skips in 15 s. 150 ms ran clean twice, then lost 514 ms of audio on the third run. 200 ms was clean three times |
| Wired buffer | 15 ms | Chosen for a jitter-free link and validated by listening |
The 15 ms wired buffer is the one figure that is not instrumented. scrcpy's own
default is 50 ms. If you hear clicks or brief dropouts that is sample skipping, so raise
it with --buffer. It sounds like glitches, not lag. See
troubleshooting.
The 150 ms result is the reason the Wi-Fi floor is 200 rather than 150. Two clean runs followed by one that lost half a second of audio is exactly the shape of a setting that looks fine until it is not, which is why every buffer figure here comes from repeated runs rather than one.