A metronome seems like a trivial app: play a click every N milliseconds. The naive implementation reaches for DispatchQueue:
// Naive approach — don't do this
func startNaive(bpm: Double) {
let interval = 60.0 / bpm
Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { _ in
self.playClick()
}
}
The problem is that Timer (and DispatchQueue.asyncAfter) deliver callbacks on the run loop, which competes with UI rendering, network I/O, and every other event in the system. Under load, a 120 BPM metronome that should fire every 500 ms will drift by 10–40 ms per beat. Over a 4-bar phrase that's an audible, rhythm-breaking wobble.
I didn't expect this to be hard. Then I measured the drift. To actually fix it on iOS, you need to bypass the run loop entirely and talk to the audio hardware directly.
AVAudioPlayer is the right tool for playing a pre-existing audio file with minimal code. It manages its own thread and is fine for casual use. But it gives you no control over when playback starts at the sample level, and chaining multiple scheduled buffers is awkward.
AVAudioEngine exposes the full audio graph. You connect nodes—source nodes, mixer nodes, effect nodes—and the engine drives them from a real-time audio thread that the OS schedules with elevated priority. The graph for Rhythmly is simple:
AVAudioPlayerNode → AVAudioMixerNode → engine.outputNode
The AVAudioPlayerNode takes scheduled audio buffers and plays them at a precise host time. The mixer node lets us adjust gain. The output node feeds the hardware.
Rather than shipping a WAV file, Rhythmly generates click sounds at runtime. This keeps the bundle small and lets us parameterize frequency and amplitude per beat type (regular click vs. accented downbeat).
A click is just a short sine wave burst with a quick exponential decay:
func makeClickBuffer(
frequency: Double,
duration: Double,
amplitude: Float,
format: AVAudioFormat
) -> AVAudioPCMBuffer? {
let sampleRate = format.sampleRate
let frameCount = AVAudioFrameCount(sampleRate * duration)
guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameCount) else {
return nil
}
buffer.frameLength = frameCount
let channelData = buffer.floatChannelData![0]
for i in 0..<Int(frameCount) {
let t = Double(i) / sampleRate
// sine wave with exponential decay
let decay = exp(-t * 30.0)
channelData[i] = amplitude * Float(sin(2.0 * .pi * frequency * t) * decay)
}
return buffer
}
Rhythmly uses 1200 Hz at amplitude 0.9 for the accent beat and 800 Hz at 0.6 for regular beats. The 30.0 decay constant makes the click about 100 ms long—short enough to feel snappy, long enough to be audible at slow tempos.
mach_absolute_time() is the monotonic clock on Apple hardware. Unlike Date or DispatchTime, it is not affected by NTP adjustments and maps directly to the audio hardware's sample clock. AVAudioTime accepts a host time value and converts it to an exact sample position on the output device.
The key insight: you do not play a click now. You schedule it to play at a future host time. The audio engine's real-time thread picks it up with zero run-loop jitter.
func offset(avTime: AVAudioTime, by seconds: Double) -> AVAudioTime {
var info = mach_timebase_info_data_t()
mach_timebase_info(&info)
// Convert seconds → mach ticks
let nanos = UInt64(seconds * 1_000_000_000)
let ticks = nanos * UInt64(info.denom) / UInt64(info.numer)
let newHostTime = avTime.hostTime + ticks
return AVAudioTime(hostTime: newHostTime)
}
The mach_timebase_info ratio converts between nanoseconds and the hardware tick unit, which varies by device. Always compute this ratio at runtime—never hard-code it.
Scheduling one click at a time is fragile: if the main thread is busy when the next beat is due, you miss the window. Rhythmly schedules 16 beats into the future at a time. When the first of those 16 fires, a completion callback schedules the next 16. This creates a rolling lookahead that absorbs main-thread latency spikes.
private let lookahead = 16
func scheduleLoop(from startTime: AVAudioTime) {
scheduleBeats(count: lookahead, from: startTime)
}
func scheduleBeats(count: Int, from startTime: AVAudioTime) {
let beatInterval = 60.0 / Double(currentBPM)
var t = startTime
for i in 0..<count {
let isAccent = (currentBeatIndex % beatsPerBar) == 0
let buffer = isAccent ? accentBuffer : regularBuffer
playerNode.scheduleBuffer(buffer, at: t, options: [], completionCallbackType: .dataPlayedBack) {
[weak self] _ in
guard let self = self, self.isRunning else { return }
if i == 0 {
// First beat of this batch played — schedule the next batch
let nextStart = self.offset(avTime: t, by: beatInterval * Double(count))
self.scheduleBeats(count: count, from: nextStart)
}
}
currentBeatIndex += 1
t = offset(avTime: t, by: beatInterval)
}
}
When the user changes BPM mid-session, the in-flight scheduled buffers are drained and a new scheduleLoop starts from the next available host time. This causes at most one beat of latency before the new tempo takes effect—imperceptible in practice.
.playback with .mixWithOthers so the metronome keeps ticking when the screen locks or the user is listening to music.