How it works

One process, one buffer, and a watchdog. The interesting parts are the constraints, not the code.

Phone→ USB or Wi-Fi→ scrcpy→ CoreAudio→ Your headphones

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:

  1. On start, the chosen device is made the macOS default output. If that fails, the bridge refuses to start rather than playing somewhere unintended.
  2. While running, the default output is polled twice a second.
  3. 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.

PieceResponsibility
bin/pabDevice selection, transport, watchdog, process lifecycle
paboutputReads and sets the macOS default output device, in under 50 ms
The appState, window, menu bar, and tearing everything down on quit

How every number was measured

QuantityValueMethod
Mac to AirPods Max, Bluetooth170.7 msCoreAudio reported device latency
Wi-Fi jitter to the phone6 to 21 ms, sigma 3.620 ping packets
Wi-Fi buffer floor200 ms50 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 buffer15 msChosen 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.