How to Implement Spatial Audio in SwiftUI

iOS 17+ Xcode 16+ Advanced APIs: PHASE Updated: May 12, 2026
TL;DR

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

  1. PHASEEngine(updateMode: .automatic) — The engine drives the audio render loop. .automatic means PHASE updates spatial calculations each render cycle without you calling update() manually, which is ideal for SwiftUI apps that move sources continuously.
  2. PHASESpatialPipeline(flags: [.directPathTransmission, .lateReverb]) — Activates two rendering stages: the direct signal from source to listener, and a convolution-based late reverb tail. Adding .earlyReflections models room geometry but costs more CPU.
  3. PHASESpatialMixerDefinition + mixerID — The mixer definition is registered at asset time, and its auto-generated identifier is stored. That same ID must be passed to addSpatialMixerParameters(identifier:source:listener:) at play time so PHASE knows which mixer to wire up.
  4. source.transform = simd_float4x4 — PHASE uses column-major 4×4 matrices (the same coordinate space as RealityKit). Changing only columns.3 moves the source's translation; rows 0–2 control orientation. Updating this property at any time repositions the sound instantly while it plays.
  5. PHASEDistanceModelFadeOutParameters(cullDistance: 20) — Sounds farther than 20 m are automatically culled from the mix, saving CPU. Pair this with PHASEGeometricSpreadingDistanceModelParameters for 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

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.