How to Build a Metronome App in SwiftUI
A metronome app gives musicians a precise, always-available tempo reference on their iPhone, with support for multiple time signatures and tap-tempo input. It's an ideal first real-device project for beginner iOS developers and a genuinely useful tool for practice sessions and teaching studios.
Prerequisites
- Mac with Xcode 16+
- Apple Developer Program ($99/year) — required for TestFlight and App Store
- Basic Swift/SwiftUI knowledge
- A physical iPhone for testing — AVAudioEngine timing is unreliable in the Simulator
- Enable the Audio, AirPlay, and Picture in Picture background mode in your target's Signing & Capabilities tab if you want ticking to continue when the screen locks
Architecture overview
The app is three layers: a MetronomeEngine @Observable class owns all AVFoundation scheduling and a high-priority DispatchSourceTimer; SwiftData persists named tempo presets; and the SwiftUI view hierarchy reads engine state via @Bindable to drive beat animations. There are no network calls, no third-party dependencies, and no entitlements beyond the background audio mode.
MetronomeApp/ ├── App/ │ └── MetronomeApp.swift ├── Engine/ │ └── MetronomeEngine.swift ← AVFoundation + DispatchSourceTimer ├── Models/ │ └── TempoPreset.swift ← @Model (SwiftData) ├── Views/ │ ├── ContentView.swift │ └── BeatIndicator.swift └── PrivacyInfo.xcprivacy
Step-by-step
1. Data model
Define a SwiftData @Model to persist named tempo presets so musicians can instantly switch between song tempos without re-dialing.
import SwiftData
import Foundation
@Model
final class TempoPreset {
var name: String
var bpm: Double
var beatsPerMeasure: Int // numerator: 2, 3, 4, 5, 6, 7…
var noteValue: Int // denominator: 4 = quarter, 8 = eighth
init(
name: String,
bpm: Double,
beatsPerMeasure: Int = 4,
noteValue: Int = 4
) {
self.name = name
self.bpm = bpm
self.beatsPerMeasure = beatsPerMeasure
self.noteValue = noteValue
}
}
// MetronomeApp.swift:
// WindowGroup { ContentView() }
// .modelContainer(for: TempoPreset.self)
2. Core UI
Build the main view with a large animated BPM readout, a tempo slider, beat indicators, and a clearly labelled start/stop button.
struct ContentView: View {
@State private var engine = MetronomeEngine()
@State private var bpm: Double = 120
var body: some View {
VStack(spacing: 28) {
Text("\(Int(bpm))")
.font(.system(size: 96, weight: .bold, design: .rounded))
.contentTransition(.numericText())
.animation(.spring, value: bpm)
Slider(value: $bpm, in: 40...240, step: 1)
.onChange(of: bpm) { engine.bpm = bpm }
.padding(.horizontal)
BeatIndicatorRow(engine: engine) // ForEach over currentBeat
Button(engine.isRunning ? "Stop" : "Start") {
if engine.isRunning { engine.stop() } else { engine.start() }
}
.buttonStyle(.borderedProminent)
.tint(engine.isRunning ? .red : .accentColor)
.controlSize(.large)
}
.padding()
}
}
3. Tempo and time signatures (the metronome engine)
Drive tick audio with a high-priority DispatchSourceTimer — never use Timer or asyncAfter here, as both drift badly above 120 BPM under any load.
@Observable
final class MetronomeEngine {
var bpm: Double = 120
var beatsPerMeasure: Int = 4
var currentBeat: Int = 0
var isRunning = false
private let audioEngine = AVAudioEngine()
private let playerNode = AVAudioPlayerNode()
private var accentBuf: AVAudioPCMBuffer?
private var tickBuf: AVAudioPCMBuffer?
private var timer: DispatchSourceTimer?
func start() {
isRunning = true; currentBeat = 0
let src = DispatchSource.makeTimerSource(
queue: .global(qos: .userInteractive))
src.schedule(deadline: .now(),
repeating: 60.0 / bpm,
leeway: .milliseconds(1))
src.setEventHandler { [weak self] in self?.tick() }
src.resume(); timer = src
}
func stop() { isRunning = false; timer?.cancel(); timer = nil }
private func tick() {
let beat = (currentBeat % beatsPerMeasure) + 1
DispatchQueue.main.async { self.currentBeat = beat }
playerNode.scheduleBuffer(beat == 1 ? accentBuf! : tickBuf!,
at: nil, options: .interrupts)
playerNode.play()
}
}
4. Privacy Manifest
Add PrivacyInfo.xcprivacy to your app target — not just the project — or Xcode 16 will block your App Store upload if any required-reason APIs are accessed.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array><string>C617.1</string></array>
</dict>
</array>
</dict>
</plist>
Common pitfalls
- Timer drift at high BPM:
TimerandDispatchQueue.main.asyncAfterrun on the main run loop and accumulate latency fast. At 200 BPM the drift becomes audible within seconds. UseDispatchSourceTimerwithleeway: .milliseconds(1)on a.userInteractivebackground queue. - Audio silenced on screen lock: Without setting
AVAudioSession.sharedInstance().setCategory(.playback, mode: .measurement)before starting your engine, iOS treats your audio as ambient and mutes it the moment the display turns off. - App backgrounding stops the beat: The audio session category alone isn't enough. You must also check the Audio, AirPlay, and Picture in Picture capability under Signing & Capabilities in your Xcode target or the metronome halts immediately when the user switches apps.
- App Store rejection for missing Privacy Manifest: Apple rejects any binary that lacks
PrivacyInfo.xcprivacyin the app target since May 2024. Add it to the target's Build Phases → Copy Bundle Resources; adding it only at the project level isn't sufficient. - Jerky tap tempo: Computing BPM from a single tap interval produces wild swings on slightly uneven taps. Average the last 4–8 inter-tap intervals and clamp the result to your valid BPM range (40–240) before applying the new tempo.
Adding monetization: One-time purchase
The simplest approach is a paid app ($2.99–$4.99) — no in-app friction and no subscription fatigue. Alternatively, ship free and gate "Pro" features behind a StoreKit 2 non-consumable IAP: custom time signatures beyond 4/4, alternate visual themes, a tap-tempo smoothing toggle, and a practice-session history view. Call Product.purchase() from the StoreKit framework, persist the entitlement with a transaction listener on app launch (Transaction.updates), and store a simple @AppStorage("isPro") bool. Always add a Restore Purchases button — App Store review guidelines require it for non-consumable IAPs and reviewers will reject the build without one.
Shipping this faster with Soarias
Soarias handles the parts of this project that aren't writing music logic: it scaffolds the Xcode project with SwiftData and AVFoundation already linked, generates a correctly structured PrivacyInfo.xcprivacy with the right API-reason codes pre-filled, configures fastlane with your App Store Connect credentials, captures App Store screenshots at every required device size, and submits the build — so you never touch the App Store Connect web UI manually.
For a beginner project at this scope, first-time App Store setup typically costs a full weekend even for developers who've done it before. With Soarias, that overhead compresses to under an hour: run one command after your archive builds, confirm the screenshots look right, and the build is live in TestFlight while you're still writing your App Store description copy.
Related guides
FAQ
Do I need a paid Apple Developer account?
Yes, for anything beyond the Simulator. A $99/year Apple Developer Program membership is required to install on a physical device with a stable provisioning profile, and it's mandatory for TestFlight distribution and App Store submission. Free provisioning works for 7-day sideloading windows but is cumbersome for ongoing testing and unsupported for distribution.
How do I submit this to the App Store?
Archive your app in Xcode (Product → Archive), upload the build to App Store Connect via Xcode Organizer or fastlane's upload_to_app_store action, fill in the required metadata (screenshots, description, support URL, privacy policy URL), set your price tier, and submit for review. First-time reviews typically resolve within 24–48 hours. Soarias automates every step after the archive.
Can the metronome keep ticking when the screen turns off?
Yes — enable the Audio, AirPlay, and Picture in Picture background mode under Signing & Capabilities in your Xcode target, and call AVAudioSession.sharedInstance().setCategory(.playback, mode: .measurement) before starting your audio engine. Both are required: the capability tells the OS to keep your process alive, and the session category tells iOS not to silence your audio stream when the display sleeps.
Last reviewed: 2026-05-12 by the Soarias team.