How I solved Bluetooth audio latency on iOS

A deep dive into AVAudioSession routing, the Objective Chirp calibration approach, real-time audio processing on the iPhone, and the engineering tradeoffs along the way.

If you practice an instrument with Bluetooth headphones and an iPhone, you've probably noticed something frustrating: you play along with the metronome, it sounds great in your ears, but when you listen back to the recording… it's off. Not by a little — by a noticeable margin. Quarter notes feel rushed. Your timing sounds sloppy even when it wasn't.

This isn't about Bluetooth monitoring latency (the delay between playing a note and hearing it in your headphones). That's a different problem, and for most practice scenarios it's tolerable. What we're talking about here is recorded audio alignment — the gap between when you played a note and where that note lands on the recording timeline.

I spent about a year building an iOS app that solves this. This is the technical story of how it works, what I tried that didn't work, and the engineering tradeoffs that shaped the final approach.

Why this is a hard problem on iOS

The root cause is the Bluetooth A2DP (Advanced Audio Distribution Profile) protocol. When you use Bluetooth headphones for audio output on iOS, the system applies an audio buffer to maintain stable playback. This buffer introduces a fixed round-trip delay — typically between 230ms and 310ms on modern iPhones and Bluetooth headphones, depending on the codec, the headphones, and the current RF conditions.

This delay is consistent enough to measure but not consistent enough to hardcode. It varies by:

Apple hasn't exposed an API to query the current Bluetooth audio buffer size or round-trip latency. AVAudioSession gives you the outputLatency property, but for Bluetooth routes this returns a value that is often unreliable or zeroed. Apple's own documentation is honest about this:

"Bluetooth connections have an inherent, ineliminable latency." — BandLab Help Center: Fixing Latency

The GarageBand forums, r/WeAreTheMusicMakers, and countless guitar forums have threads going back years asking for a solution. Apple hasn't built one into iOS. Third-party DAWs like GarageBand and Logic don't compensate for Bluetooth playback latency — they assume a wired monitoring path. So if you wanted to record through Bluetooth headphones and get aligned playback, you were out of luck.

The calibration approach: measuring what the system won't tell you

Since iOS doesn't expose the round-trip latency directly, the only reliable way to get it is to measure it yourself. This is the core idea behind what I call the Objective Chirp calibration.

The concept is straightforward:

  1. Play a known audio signal (the "chirp") through the Bluetooth headphones
  2. Record that signal through the iPhone microphone as it plays in the room
  3. Cross-correlate the recorded signal with the original to find the time offset
  4. The offset is the round-trip latency

In practice, "straightforward" took months to stabilize. Here's what I learned.

Signal design

The chirp needs to be short enough to not annoy the user, loud enough to be picked up by the iPhone mic in a typical practice room, and spectrally rich enough to produce a sharp detection peak. A pure sine wave won't work — the transient onset is too easy to miss against room ambience. White noise works but sounds terrible coming out of someone's headphones.

I settled on a short logarithmic frequency sweep from 2 kHz to 6 kHz, about 20ms per chirp. Each chirp is shaped with a sine-squared envelope to concentrate energy in the passband while avoiding audible clicks at the onset and release. The sweep range avoids the lower frequencies (which are often masked by room rumble or hum) and stays within a range where Bluetooth A2DP codecs (AAC/SBC) reproduce cleanly.

The calibration plays 16 such chirps at 1-second intervals — about 16 seconds total. The user just holds the phone near their headphones (a single earbud near the iPhone bottom mic is enough) and waits for the chime sequence to finish. A 1.5-second pre-roll before the first chirp ensures the audio engine and input tap are fully settled before measurement begins.

// Swift — chirp generation from the shipping CalibrationManager
func makeChirpSequence(clickCount: Int, interval: Double, sampleRate: Double) -> AVAudioPCMBuffer {
    let totalSeconds = (Double(clickCount - 1) * interval) + 0.02
    let capacity = AVAudioFrameCount(ceil(totalSeconds * sampleRate))
    let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: capacity)!
    buffer.frameLength = capacity

    for click in 0..<clickCount {
        let startFrame = Int(Double(click) * interval * sampleRate)
        for offset in 0..<Int(0.02 * sampleRate) {
            let progress = Double(offset) / Double(Int(0.02 * sampleRate) - 1)
            let frequency = 2000.0 + (6000.0 - 2000.0) * progress     // 2kHz→6kHz sweep
            let envelope = sin(.pi * progress)                          // sine² shaping
            let phase = 2.0 * .pi * frequency * Double(offset) / sampleRate
            buffer[channel][startFrame + offset] = Float(sin(phase) * envelope * 0.8)
        }
    }
    return buffer
}

Peak detection and acceptance

With a single `AVAudioEngine` hosting both the playback and recording input tap, the playback and recording timebases share the same audio clock. This avoids the wall-clock drift that plagued earlier prototypes.

The detection itself is straightforward. For each of the 16 expected chirp positions, the analyzer searches a window from 150ms before to 350ms after the expected time, sliding in 1ms steps with a 20ms RMS window:

func searchClick(samples: [Float], sampleRate: Double, expectedSec: Double) -> (detectedTimeSec: Double, peak: Float, rms: Float) {
    let searchStart = max(0, Int((expectedSec - 0.15) * sampleRate))
    let searchEnd = min(samples.count, Int((expectedSec + 0.35) * sampleRate))
    let stepFrames = max(1, Int(0.001 * sampleRate))
    let windowFrames = max(1, Int(0.02 * sampleRate))

    var bestFrame = searchStart
    var bestPeak: Float = 0

    for frame in stride(from: searchStart, to: searchEnd - windowFrames, by: stepFrames) {
        let peak = maxAbs(samples, range: frame ..< frame + windowFrames)
        if peak > bestPeak {
            bestPeak = peak
            bestFrame = frame
        }
    }

    return (Double(bestFrame) / sampleRate, bestPeak, computeRMS(samples, range: bestFrame ..< bestFrame + windowFrames))
}

A match is accepted if the peak amplitude exceeds a fixed threshold of 0.12 (calibrated against real-world recordings made at typical practice distances — phone 30-60cm from the headphones). If 12 or more of the 16 chirps are accepted, the calibration passes with green status. Between 8 and 11 is yellow (usable but noisy), and below 8 is red (try again).

In production, the first-attempt success rate is about 99%. The edge cases that cause failure are almost always the same: the phone is too far from the headphones, or there's enough ambient noise that the chirp is buried. A simple on-screen prompt ("Move the phone closer to your headphones") resolves nearly all of them.

The final offset is the median of all accepted deltas, and the jitter is the median absolute deviation (MAD). This is robust against a few outliers — if the user coughs during one chirp, that single corrupted measurement is rejected by the threshold, and the remaining 15 still produce a stable result.

Typical results: The calibration consistently measures Bluetooth round-trip latency in the 230-310ms range, with jitter typically below 2ms on successive runs. The variation between runs on the same hardware is within ±1ms.

The compensation engine: delaying the timeline, not the audio

Once you have the measured latency, applying compensation sounds simple: just subtract the measured delay from the playback timeline. The complication is that iOS audio routes can change during a recording session, and the latency profile changes with them.

Consider these scenarios:

The iOS audio routing system is complex, and AVAudioSession.routeChangeReason doesn't always fire when you'd expect. I spent a disproportionate amount of time on what I call the Route Guard — a monitoring layer that tracks the active audio route throughout a recording session.

class RouteGuard {
    private var observation: NSKeyValueObservation?

    func startMonitoring() {
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(routeChanged),
            name: AVAudioSession.routeChangeNotification,
            object: AVAudioSession.sharedInstance()
        )

        // Also poll the current route periodically
        observation = AVAudioSession.sharedInstance().observe(
            \.currentRoute,
            options: [.initial, .new]
        ) { [weak self] session, _ in
            self?.evaluateRoute(session.currentRoute)
        }
    }

    @objc private func routeChanged(_ notification: Notification) {
        guard let reason = notification.userInfo?[AVAudioSessionRouteChangeReasonKey]
                as? UInt else { return }

        if reason == AVAudioSession.RouteChangeReason.routeConfigurationChange.rawValue
            || reason == AVAudioSession.RouteChangeReason.newDeviceAvailable.rawValue
            || reason == AVAudioSession.RouteChangeReason.oldDeviceUnavailable.rawValue {

            // Re-evaluate: is the current calibration still valid?
            DispatchQueue.main.async { [weak self] in
                self?.notifyPotentialRouteChange()
            }
        }
    }
}

The Route Guard's job is not to re-calibrate automatically (that would be disruptive mid-take). Instead, it flags the current take as "calibration may be stale" and prompts the user to recalibrate before the next take. This is a deliberate tradeoff: we accept the risk of a slightly misaligned take in exchange for never disrupting a recording in progress.

Timeline compensation vs. streaming compensation

There's an important architectural decision here: when to apply the compensation.

A streaming approach would delay the audio playback buffer by the measured offset — effectively delaying what the user hears in real time to match the recording timeline. This is what a DAW with "monitor latency compensation" does. But on iOS, this is impractical for Bluetooth output because you can't individually control the output buffer delay without audio glitches.

The alternative, which I chose, is timeline compensation. The recording is captured at the system's natural timing. The measured offset is stored as metadata alongside the audio buffer. When the user plays back or exports the take, the playback timeline is shifted by the measured offset. The raw audio is never modified — only the playback position is adjusted.

Approach Pros Cons
Streaming compensation Real-time; user hears aligned audio during recording Hard on iOS; buffer manipulation causes glitches; Bluetooth route changes break it
Timeline compensation (chosen) Reliable; simple architecture; works across route changes User hears raw latency during recording; alignment only visible on playback/export

This was the right call for a practice-recorder use case. The user doesn't need to hear aligned audio while playing — they need to hear it when reviewing the take. During recording, they're focused on playing. The metronome in their Bluetooth headphones is perfectly usable. It's only when they stop to evaluate the take that alignment matters.

AVAudioSession: the unsexy battle

If I'm being honest, the chirp calibration and the compensation engine were the fun parts. The hard, time-consuming, not-remotely-glamorous part was AVAudioSession management.

iOS has a specific idea of what an audio app should do, and "record with the iPhone mic while playing back through Bluetooth headphones" is a category that doesn't fit neatly into any of the standard session categories (.playback, .record, .playAndRecord). Each category comes with a set of assumptions about routing, mixing, and ducking behavior.

The critical configuration for TakeOne:

let session = AVAudioSession.sharedInstance()

try session.setCategory(.playAndRecord,
    mode: .default,
    options: [
        .defaultToSpeaker,       // iPhone mic is the default input
        .allowBluetoothA2DP,     // Allow Bluetooth output
        .overrideMutedMicrophoneInterruption
    ])

try session.setPreferredIOBufferDuration(0.005) // 5ms buffer for responsiveness

The .allowBluetoothA2DP option is critical. Without it, iOS defaults to the HFP (Hands-Free Profile) for Bluetooth connections when both playback and recording are active. HFP uses a narrowband or wideband speech codec with terrible audio quality and high latency. A2DP is the high-quality stereo profile — but iOS only uses it when the session category "allows" it, and even then, only if no recording input is needed from the Bluetooth headset mic.

This is actually the right behavior for TakeOne's use case: we want the iPhone mic for recording and the Bluetooth headphones for playback. But getting iOS to agree to this routing without occasionally switching to the headset mic or disabling A2DP output took a lot of trial and error.

One specific issue: if you set the category before the Bluetooth headphones are connected, the routing behavior differs from setting it after. The session configuration is not commutative with Bluetooth pairing events. I ended up deferring the final session activation to the moment the user starts a recording, after confirming the route state.

What I learned about precision and tolerance

Early in development, I was obsessed with getting the calibration as precise as possible. I spent weeks optimizing the cross-correlation, reducing jitter, handling edge cases at sub-millisecond precision.

Then I actually tested it with musicians. It turned out that ±10ms of jitter is imperceptible in a practice-recording context. What matters is that the playback is close enough that the musician can trust the recording to evaluate their timing. A 10ms error sounds like "slightly loose feel." A 100ms error (which is what you get without compensation) sounds like "I can't play in time."

The goal is not studio-grade sample-accurate alignment. The goal is making the recording useful for practice feedback.

This realization changed how I approached the calibration UI. Instead of showing the user an intimidating "Latency: 128.47ms" display, the app shows "Aligned ✓" after a successful calibration. The exact number is available if they expand the technical details, but the default experience is confidence-oriented, not precision-oriented.

Limitations and what I couldn't solve

No engineering post is honest without acknowledging the gaps:

Why I built this as a product

I started this as a personal tool. I play guitar late at night, and I wanted to record practice takes with my AirPods without the timing being off. After I got the calibration working, I realized I wasn't the only one with this problem — the GarageBand forums and music subreddits are full of people asking the same question.

TakeOne is the result. It's a free iOS app that packages this latency compensation into a simple practice-recorder workflow: calibrate your Bluetooth output once, record a take with a count-in and metronome, and listen back to aligned playback. Optional one-time purchases (Plus and Pro) unlock a saved-takes library and three-track Layered Takes — but the core compensation is free for everyone.

The app is designed for musicians who practice with headphones and want to hear what they actually played — not what Bluetooth latency did to their recording.

All audio processing happens on-device. No accounts, no uploads, no subscriptions.

More from the blog