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:
- Headphone model — AirPods Pro 2 behave differently from AirPods 4, Sony WH-1000XM5, or Beats Studio Buds
- Active audio route — The same headphones can show different latency depending on whether they're connected as "Headphones" or "Headset" mode
- Codec negotiation — AAC vs SBC, and the specific bitpool negotiated between device and headphones
- Environmental RF conditions — Interference can cause the Bluetooth stack to adjust buffering
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:
- Play a known audio signal (the "chirp") through the Bluetooth headphones
- Record that signal through the iPhone microphone as it plays in the room
- Cross-correlate the recorded signal with the original to find the time offset
- 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.
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 user starts recording with AirPods, then takes one earbud out — the route switches to iPhone speaker or wired
- An incoming call interrupts the session — the audio route switches to the call, then back
- The user puts on the headphones after opening the app — the initial calibration was against a different route
- The Bluetooth connection momentarily degrades and the stack renegotiates buffering
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:
- Monitoring latency is not compensated. The user still hears their instrument delayed through Bluetooth during recording. This is a separate problem that requires hardware-level direct monitoring to solve — no software-only approach can eliminate the A2DP encode/decode cycle.
- Very quiet environments can challenge the chirp calibration. If the user's headphones have excellent passive noise isolation and the phone is on the other side of the room, the chirp may not be loud enough for a clean correlation peak.
- Routing changes mid-take are detected but not adaptively corrected. The session notes "calibration may be stale" and the user recalibrates before the next take.
- LE Audio / LC3 is coming. Apple hasn't fully adopted it yet on the iPhone side, but when they do, the latency characteristics will change significantly. My calibration approach should adapt (it measures whatever the current route delivers), but the compensation logic may need updates.
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
- How I practice guitar with a virtual band on my iPhone Song Sketch and Practice Band — drums, bass, and pad that follow your song.
- How I record three guitar parts on my iPhone without opening a DAW Layered Takes — a simple workflow for stacking rhythm, solo, and vocals on the go.