How to Implement Spatial Audio in SwiftUI
Use Apple's PHASE (Physical Audio Spatialization Engine) to place sounds at arbitrary 3D positions relative to a listener. Register an audio asset, define a spatial sound event with a PHASESpatialPipeline, then add a PHASESource and PHASEListener to the scene and play.
import PHASE
let engine = PHASEEngine(updateMode: .automatic)
try engine.start()
// Register audio file
let url = Bundle.main.url(forResource: "whoosh", withExtension: "caf")!
try engine.assetRegistry.registerSoundAsset(
url: url, identifier: "whoosh",
assetType: .resident, channelLayout: nil, normalizationMode: .dynamic)
// Spatial pipeline → mixer → sampler
let pipeline = PHASESpatialPipeline(flags: [.directPathTransmission, .lateReverb])!
let mixerDef = PHASESpatialMixerDefinition(spatialPipeline: pipeline)
let sampler = PHASESamplerNodeDefinition(soundAssetIdentifier: "whoosh",
mixerDefinition: mixerDef)
sampler.playbackMode = .looping
try engine.assetRegistry.registerSoundEventAsset(rootNode: sampler, identifier: "whooshEvent")
// Scene: listener at origin, source 3 m to the right
let listener = PHASEListener(engine: engine)
try engine.scene.add(listener)
let source = PHASESource(engine: engine)
var t = matrix_identity_float4x4; t.columns.3 = SIMD4(3, 0, 0, 1)
source.transform = t
try engine.scene.add(source)
// Play
let params = PHASEMixerParameters()
params.addSpatialMixerParameters(identifier: mixerDef.identifier,
source: source, listener: listener)
let event = try PHASESoundEvent(engine: engine, assetIdentifier: "whooshEvent",
mixerParameters: params)
try event.start()
Full implementation
The example below wraps the PHASE engine in an @Observable class so SwiftUI can react to playback state changes. A draggable 2D pad lets the user reposition the audio source in real time — updating the source's simd_float4x4 transform moves the sound immediately, without restarting the sound event. Use a real device with headphones; the spatial effect is inaudible in the Simulator.
import SwiftUI
import PHASE
// MARK: - Audio manager
@Observable
final class SpatialAudioManager {
private(set) var isPlaying = false
var sourceX: Float = 0 // metres left / right
var sourceZ: Float = -2 // metres in front of listener
private let engine: PHASEEngine
private var source: PHASESource?
private var listener: PHASEListener?
private var activeEvent: PHASESoundEvent?
private var mixerID = ""
private let assetID = "SpatialAsset"
private let soundEventID = "SpatialEvent"
init() { engine = PHASEEngine(updateMode: .automatic) }
// MARK: Setup
func start() throws {
try engine.start()
try registerAssets()
try buildScene()
}
private func registerAssets() throws {
guard let url = Bundle.main.url(forResource: "drone", withExtension: "caf") else {
throw URLError(.fileDoesNotExist)
}
try engine.assetRegistry.registerSoundAsset(
url: url, identifier: assetID,
assetType: .resident, channelLayout: nil, normalizationMode: .dynamic)
let pipeline = PHASESpatialPipeline(flags: [.directPathTransmission, .lateReverb])!
let mixerDef = PHASESpatialMixerDefinition(spatialPipeline: pipeline)
// Geometric rolloff – fades to silence at 20 m
let rolloff = PHASEGeometricSpreadingDistanceModelParameters()
rolloff.fadeOutParameters =
PHASEDistanceModelFadeOutParameters(cullDistance: 20)
mixerDef.distanceModelParameters = rolloff
mixerID = mixerDef.identifier // capture for PHASEMixerParameters
let sampler = PHASESamplerNodeDefinition(
soundAssetIdentifier: assetID, mixerDefinition: mixerDef)
sampler.playbackMode = .looping
try engine.assetRegistry.registerSoundEventAsset(
rootNode: sampler, identifier: soundEventID)
}
private func buildScene() throws {
let l = PHASEListener(engine: engine)
l.transform = matrix_identity_float4x4
try engine.scene.add(l)
listener = l
let s = PHASESource(engine: engine)
s.transform = transform(x: sourceX, z: sourceZ)
try engine.scene.add(s)
source = s
}
// MARK: Playback
func play() throws {
guard let source, let listener else { return }
let params = PHASEMixerParameters()
params.addSpatialMixerParameters(
identifier: mixerID, source: source, listener: listener)
let event = try PHASESoundEvent(
engine: engine, assetIdentifier: soundEventID, mixerParameters: params)
try event.start()
activeEvent = event
isPlaying = true
}
func stopPlayback() {
activeEvent?.stopAndInvalidate()
activeEvent = nil
isPlaying = false
}
/// Call after changing sourceX / sourceZ to update 3D position live.
func commitPosition() {
source?.transform = transform(x: sourceX, z: sourceZ)
}
// MARK: Helpers
private func transform(x: Float, z: Float) -> simd_float4x4 {
var m = matrix_identity_float4x4
m.columns.3 = SIMD4<Float>(x, 0, z, 1)
return m
}
}
// MARK: - SwiftUI view
struct SpatialAudioView: View {
@State private var audio = SpatialAudioManager()
@State private var setupError: String?
var body: some View {
VStack(spacing: 28) {
Text("Spatial Audio")
.font(.title2.bold())
// 2D drag pad — drag dot = audio source position
GeometryReader { geo in
ZStack {
RoundedRectangle(cornerRadius: 16)
.fill(Color(.systemGray6))
crosshair
Circle()
.fill(Color.accentColor)
.frame(width: 36, height: 36)
.shadow(radius: 4)
.offset(dotOffset(in: geo.size))
.accessibilityLabel("Audio source")
}
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { v in
let w = Float(geo.size.width), h = Float(geo.size.height)
audio.sourceX = clamp((Float(v.location.x) / w - 0.5) * 10, -5, 5)
audio.sourceZ = clamp((Float(v.location.y) / h - 0.5) * 10, -5, 5)
audio.commitPosition()
}
)
}
.frame(height: 260)
Button(audio.isPlaying ? "Stop" : "Play Spatial Sound") {
if audio.isPlaying { audio.stopPlayback() }
else { try? audio.play() }
}
.buttonStyle(.borderedProminent)
.accessibilityHint(audio.isPlaying ? "Stops looping drone" : "Starts looping drone at current 3D position")
Text("Drag the dot to move the sound source · Use headphones")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding()
.task {
do { try audio.start() }
catch { setupError = error.localizedDescription }
}
.alert("PHASE Error", isPresented: .constant(setupError != nil)) {
Button("OK") { setupError = nil }
} message: { Text(setupError ?? "") }
}
private var crosshair: some View {
Image(systemName: "person.fill")
.foregroundStyle(.tertiary)
.font(.title)
.accessibilityLabel("Listener — you")
}
private func dotOffset(in size: CGSize) -> CGSize {
CGSize(
width: Double(audio.sourceX / 5) * size.width / 2,
height: Double(audio.sourceZ / 5) * size.height / 2
)
}
private func clamp(_ v: Float, _ lo: Float, _ hi: Float) -> Float {
min(max(v, lo), hi)
}
}
#Preview {
SpatialAudioView()
}
How it works
-
PHASEEngine(updateMode: .automatic) — The engine drives the audio render loop.
.automaticmeans PHASE updates spatial calculations each render cycle without you callingupdate()manually, which is ideal for SwiftUI apps that move sources continuously. -
PHASESpatialPipeline(flags: [.directPathTransmission, .lateReverb]) — Activates two rendering stages: the direct signal from source to listener, and a convolution-based late reverb tail. Adding
.earlyReflectionsmodels room geometry but costs more CPU. -
PHASESpatialMixerDefinition + mixerID — The mixer definition is registered at asset time, and its auto-generated
identifieris stored. That same ID must be passed toaddSpatialMixerParameters(identifier:source:listener:)at play time so PHASE knows which mixer to wire up. -
source.transform = simd_float4x4 — PHASE uses column-major 4×4 matrices (the same coordinate space as RealityKit). Changing only
columns.3moves the source's translation; rows 0–2 control orientation. Updating this property at any time repositions the sound instantly while it plays. -
PHASEDistanceModelFadeOutParameters(cullDistance: 20) — Sounds farther than 20 m are automatically culled from the mix, saving CPU. Pair this with
PHASEGeometricSpreadingDistanceModelParametersfor physically-correct inverse-square-law rolloff.
Variants
One-shot spatial sound effect
For impact sounds or UI feedback that should play once and stop, set playbackMode = .oneShot and observe the sound event's completionHandler to clean up.
// In registerAssets()
let sampler = PHASESamplerNodeDefinition(
soundAssetIdentifier: assetID, mixerDefinition: mixerDef)
sampler.playbackMode = .oneShot // ← plays once then stops
// When triggering
let event = try PHASESoundEvent(engine: engine,
assetIdentifier: soundEventID, mixerParameters: params)
event.completionHandler = { [weak self] reason in
// .finished = played to end; .terminated = stopped manually
DispatchQueue.main.async { self?.isPlaying = false }
}
try event.start()
Animate source along a path using withAnimation
Because PHASE reads transforms directly, you can drive them from a TimelineView or a timer for orbit-style effects. Call audio.sourceX = cos(angle) * radius and audio.commitPosition() inside the timeline's closure for smooth circular motion — no withAnimation needed since PHASE interpolates audio, not the view layer.
Common pitfalls
- Simulator has no spatial audio renderer. PHASE initialises without error on the Simulator, but the directional effect is silent or mono. Always test on a device with headphones or AirPods; the Simulator is only useful for testing non-crash logic.
-
Forgetting to store
mixerDef.identifierbefore the def goes out of scope. The identifier is auto-generated at init time. If you create a newPHASESpatialMixerDefinitioninstance insideplay()instead of reusing the stored ID,addSpatialMixerParameters(identifier:)silently mismatches and no sound is heard. -
Audio session category must allow output. Set
try AVAudioSession.sharedInstance().setCategory(.playback)before starting the engine, or PHASE will fail to acquire the hardware render thread — particularly after a phone call or Siri interruption interrupts and suspends the session. -
Accessibility: spatial panning is imperceptible without headphones. Provide a fallback visual indicator (the drag dot in the example) and add
.accessibilityLabelto describe position numerically — e.g., "Sound source: 3 metres right, 2 metres ahead" — for VoiceOver users who may be listening on speaker.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement spatial audio in SwiftUI for iOS 17+. Use PHASE framework: PHASEEngine, PHASESpatialPipeline, PHASESpatialMixerDefinition, PHASESource, PHASEListener. Register a looping .caf asset from the app bundle. Add an @Observable manager class and a SwiftUI view with a 2D drag pad to reposition the source in real time. Make it accessible (VoiceOver labels with metric distances). Add a #Preview with realistic sample data.
In the Soarias Build phase, paste this prompt into the active session after scaffolding your audio asset in the project navigator — Claude Code will wire the PHASE pipeline directly into your existing ContentView or a dedicated AudioView file.
Related
FAQ
Does this work on iOS 16?
PHASE was introduced in iOS 15, so the core APIs compile and run on iOS 15 and 16. However, several distance model and pipeline flags were refined in iOS 17 — notably improved HRTF rendering for AirPods Pro. Targeting iOS 17+ also lets you use @Observable instead of the older ObservableObject pattern shown here. If you must support iOS 15–16, replace @Observable with @ObservableObject + @Published.
Can I use PHASE with AVAudioEngine at the same time?
Yes, but they compete for the same AVAudioSession hardware route. Activate the session with .playback or .playAndRecord category before starting either engine, and listen for AVAudioSession.interruptionNotification to restart PHASE after phone calls or other interruptions. Do not mix PHASEEngine and AVAudioEngine on the same audio graph node simultaneously.
What's the UIKit / AVFoundation equivalent?
Before PHASE, spatial audio in UIKit was done via AVAudioEnvironmentNode attached to an AVAudioEngine graph, with AVAudio3DMixingRenderingAlgorithm.HRTFHQ. PHASE replaces that approach entirely with a scene-graph model and proper distance modelling. For simple left/right panning only, AVAudioPlayerNode.pan (–1 to +1) still works and requires no scene setup.
Last reviewed: 2026-05-12 by the Soarias team.