How it works

Two native apps and one small wire protocol. Here is how audio gets from Core Audio on a Mac to AudioTrack on a phone, and why it stays in sync.

In short

  • The Mac captures the system mix (ScreenCaptureKit or BlackHole) and sends uncompressed 16-bit PCM in 10 ms packets over plain TCP.
  • The phone races every route in the QR code (USB, Wi-Fi, VPN) and keeps the first to answer.
  • A jitter buffer (90 ms by default) absorbs network hiccups, and playback runs a fraction of a percent fast or slow to cancel clock drift, so latency never creeps up.
  • A random token authenticates the phone; the audio itself is not encrypted.

Overview

The Mac is the server. It captures audio, cuts it into fixed packets and pushes them to every connected phone over TCP. The phone is the client. It connects, buffers, corrects for clock drift and plays.

  1. MacCaptureScreenCaptureKit or AVAudioEngine
  2. MacServeper-client queues, pings
  3. LinkTCPUSB or Wi-Fi
  4. PhoneBuffertarget depth, drop-oldest
  5. PhonePlayAudioTrack, rate-trimmed

The audio is uncompressed 16-bit stereo PCM, 48 kHz for system capture. No codec means no encoding delay and no quality loss. The cost is bandwidth, about 1.5 Mbit/s, which is nothing on USB or Wi-Fi.

Capture on the Mac

Both capture methods implement one Swift protocol, AudioSource. Both deliver fixed 480-frame blocks (10 ms at 48 kHz), whatever buffer size Core Audio uses internally. A steady cadence is much easier on a jitter buffer than whatever the hardware felt like that moment.

Screen Recording: ScreenCaptureKit

An SCStream with capturesAudio set delivers the full system mix, with no virtual driver. ScreenCaptureKit can't switch video off, so Tethertone asks for a 2×2 pixel frame twice a second and throws it away. excludesCurrentProcessAudio keeps Tethertone's own sound out of the stream, which prevents feedback.

BlackHole: AVAudioEngine

An AVAudioEngine whose input is pinned to a chosen Core Audio device, normally BlackHole. An optional monitor plays a copy to a real output device, so the Mac doesn't go silent when its output is routed into BlackHole.

The wire protocol

A plain TCP connection, not WebSocket or HTTP. It opens with two lines of JSON: the phone's HELLO with its token, and the Mac's WELCOME with the sample rate, channel count and format. After that, everything is binary frames with an 8-byte header:

 0      1      2        4                8
 +------+------+--------+----------------+
 | type | flags| length |      seq       |   big-endian
 +------+------+--------+----------------+
 |          payload (length bytes)        |
 +----------------------------------------+
TypeDirectionPurpose
AUDIOMac → phoneinterleaved PCM; gaps in seq are reported as lost packets
PING / PONGbothround-trip time, measured on the Mac where both timestamps share a clock
STATSphone → Macbuffer depth, underruns, loss and playback speed, shown in the Mac's window
BYEbothclean disconnect with a reason

Each connected phone has its own bounded send queue on the Mac. If a phone falls behind, its oldest packets are dropped. A slow phone never stalls capture or the other listeners. The full specification is in PROTOCOL.md.

Routes and racing

The pairing QR code is an tethertone:// link listing every address the Mac can be reached at, best first:

  1. 127.0.0.1, when a phone is plugged in and adb reverse succeeded;
  2. the Mac's Wi-Fi and Ethernet addresses;
  3. VPN addresses such as Tailscale;
  4. an optional hostname you add yourself.

The phone starts a connection to each, a few milliseconds apart, and keeps the first that completes the handshake. The cable wins whenever it's plugged in. When a connection drops, the phone reconnects on its own with exponential backoff.

The jitter buffer

Networks deliver packets in bursts. The jitter buffer holds audio between the network and the speaker so playback can be perfectly regular. It has a target depth, 90 ms by default and adjustable from 20 to 250 ms. If it runs dry, the phone plays silence and counts an underrun. If it overflows, the oldest audio is discarded.

A bigger buffer survives worse networks and adds latency; a smaller one is snappier and more fragile. On USB, 40–60 ms is usually solid.

Clock drift, the hard problem

The Mac's audio clock and the phone's never agree exactly; they differ by tens of parts per million. That sounds tiny, but a buffer corrected only when it runs empty drifts steadily toward starving or overflowing. Over an hour, that's seconds of extra delay.

Tethertone holds the buffer at its target by playing a fraction of a percent faster or slower than nominal. At ±0.2 % the pitch changes by about three cents, far below what anyone can hear. The audio never jumps. The phone's Clock drift card shows it live, for example “playing 204 ppm fast to drain the buffer”.

Two findings from real phones shaped the Android audio sink:

  • AudioTrack.setPlaybackParams, the modern API, is rejected by tracks created in low-latency mode. The older setPlaybackRate works.
  • On some devices a low-latency track refuses both. Smooth correction matters more than the few milliseconds low-latency mode saves, so Tethertone checks for rate control and, if it's refused, rebuilds the track without low-latency mode.

If no rate control exists at all, the buffer is kept in range by occasionally discarding a little audio instead: a click every few minutes rather than latency that grows without limit. The app says which mode is active.

Security model

The QR code carries a 16-byte random token, stored on the Mac with owner-only permissions. The Mac compares it in constant time and sends nothing before a valid HELLO. That keeps other people on your network from listening.

The audio itself is not encrypted. On a network you don't trust, use USB or a VPN. Rotate the token under Pairing › Generate a new token, and every paired phone has to scan again. Details are in the security policy.

Testing

The protocol is implemented three times, independently:

ImplementationLanguageChecked by
macOS serverSwiftTethertone --selftest: fixed byte vectors
Android/JVM clientKotlingradlew :shared:jvmTest: the same vectors
tools/probe.pyPythonrun against a live server

The Kotlin client's logic has no Android dependencies, so it also runs on a desktop JVM. When a phone misbehaves, that answers whether the protocol or Android is at fault within seconds. Read more in ARCHITECTURE.md.