Spatial Audio on iOS 18 don't work as inteneded

I’m facing a problem while trying to achieve spatial audio effects in my iOS 18 app. I have tried several approaches to get good 3D audio, but the effect never felt good enough or it didn’t work at all.

Also what mostly troubles me is I noticed that AirPods I have doesn’t recognize my app as one having spatial audio (in audio settings it shows "Spatial Audio Not Playing"). So i guess my app doesn't use spatial audio potential.

  1. First approach uses AVAudioEnviromentNode with AVAudioEngine. Chaining position of player as well as changing listener’s doesn’t seem to change anything in how audio plays.

Here's simple how i initialize AVAudioEngine

import Foundation
import AVFoundation

class AudioManager: ObservableObject {
// important class variables
    var audioEngine: AVAudioEngine!
    var environmentNode: AVAudioEnvironmentNode!
    var playerNode: AVAudioPlayerNode!
    var audioFile: AVAudioFile?
...
   //Sound set up
func setupAudio() {
        do {
            let session = AVAudioSession.sharedInstance()
            try session.setCategory(.playback, mode: .default, options: [])
            try session.setActive(true)
        } catch {
            print("Failed to configure AVAudioSession: \(error.localizedDescription)")
        }

        audioEngine = AVAudioEngine()
        environmentNode = AVAudioEnvironmentNode()
        playerNode = AVAudioPlayerNode()
        audioEngine.attach(environmentNode)
        audioEngine.attach(playerNode)
        audioEngine.connect(playerNode, to: environmentNode, format: nil)
        audioEngine.connect(environmentNode, to: audioEngine.mainMixerNode, format: nil)
        environmentNode.listenerPosition = AVAudio3DPoint(x: 0, y: 0, z: 0)
        environmentNode.listenerAngularOrientation = AVAudio3DAngularOrientation(yaw: 0, pitch: 0, roll: 0)
      environmentNode.distanceAttenuationParameters.referenceDistance = 1.0        environmentNode.distanceAttenuationParameters.maximumDistance = 100.0
        environmentNode.distanceAttenuationParameters.rolloffFactor = 2.0
        // example.mp3 is mono sound
        guard let audioURL = Bundle.main.url(forResource: "example", withExtension: "mp3") else {
            print("Audio file not found")
            return
        }

        do {
            audioFile = try AVAudioFile(forReading: audioURL)
        } catch {
            print("Failed to load audio file: \(error)")
        }
   }
...
    //Playing sound
    func playSpatialAudio(pan: Float ) {
        guard let audioFile = audioFile else { return }
        //  left side
        playerNode.position = AVAudio3DPoint(x: pan, y: 0, z: 0)
        playerNode.scheduleFile(audioFile, at: nil, completionHandler: nil)

        do {
            try audioEngine.start()
            playerNode.play()
        } catch {
            print("Failed to start audio engine: \(error)")
        }
...
}    
  1. Second more complex approach using PHASE did better. I’ve made an exemplary app that allows players to move audio player in 3D space. I have added reverb, and sliders changing audio position up to 10 meters each direction from listener but audio seems to only really change left to right (x axis) - again I think it might be trouble with the app not being recognized as spatial.
//Crucial class Variables:
class PHASEAudioController: ObservableObject{
    private var soundSourcePosition: simd_float4x4 = matrix_identity_float4x4
    private var audioAsset: PHASESoundAsset!
    private let phaseEngine: PHASEEngine
    private let params = PHASEMixerParameters()
    private var soundSource: PHASESource
    private var phaseListener:  PHASEListener!
    private var soundEventAsset: PHASESoundEventNodeAsset?

// Initialization of PHASE
init{     
 do {
            let session = AVAudioSession.sharedInstance()
            try session.setCategory(.playback, mode: .default, options: [])

            try session.setActive(true)
        } catch {
            print("Failed to configure AVAudioSession: \(error.localizedDescription)")
        }
        // Init PHASE Engine
        phaseEngine = PHASEEngine(updateMode: .automatic)
        phaseEngine.defaultReverbPreset = .mediumHall
        phaseEngine.outputSpatializationMode = .automatic //nothing helps        
        // Set listener position to (0,0,0) in World space
        let origin: simd_float4x4 = matrix_identity_float4x4
        phaseListener = PHASEListener(engine: phaseEngine)
        phaseListener.transform = origin
        phaseListener.automaticHeadTrackingFlags = .orientation
        try! self.phaseEngine.rootObject.addChild(self.phaseListener)
        do{
            try self.phaseEngine.start();
        }
        catch {
            print("Could not start PHASE engine")
        }
 
        audioAsset = loadAudioAsset()
        // Create sound Source
        // Sphere
        soundSourcePosition.translate(z:3.0)
        let sphere = MDLMesh.newEllipsoid(withRadii: vector_float3(0.1,0.1,0.1), radialSegments: 14, verticalSegments: 14, geometryType: MDLGeometryType.triangles, inwardNormals: false, hemisphere: false, allocator: nil)
        let shape = PHASEShape(engine: phaseEngine, mesh: sphere)
        soundSource = PHASESource(engine: phaseEngine, shapes: [shape])
        soundSource.transform = soundSourcePosition
        print(soundSourcePosition)
        do {
            try phaseEngine.rootObject.addChild(soundSource)
        }
        catch {
            print ("Failed to add a child object to the scene.")
        }
        let simpleModel = PHASEGeometricSpreadingDistanceModelParameters()
        simpleModel.rolloffFactor = rolloffFactor
        soundPipeline.distanceModelParameters = simpleModel
        
        let samplerNode = PHASESamplerNodeDefinition(
            soundAssetIdentifier: audioAsset.identifier,
            mixerDefinition: soundPipeline,
            identifier: audioAsset.identifier + "_SamplerNode")
        samplerNode.playbackMode = .looping
        do {soundEventAsset = try
            phaseEngine.assetRegistry.registerSoundEventAsset(
            rootNode: samplerNode,
            identifier: audioAsset.identifier + "_SoundEventAsset")
        } catch {
            print("Failed to register a sound event asset.")
            soundEventAsset = nil
        }
}

//Playing sound
 func playSound(){
        // Fire new sound event with currently set properties
        guard let soundEventAsset else { return }
        
        params.addSpatialMixerParameters(
            identifier: soundPipeline.identifier,
            source: soundSource,
            listener: phaseListener)
        let soundEvent = try! PHASESoundEvent(engine: phaseEngine,
                                              assetIdentifier: soundEventAsset.identifier,
                                              mixerParameters: params)
        soundEvent.start(completion: nil)
    }
...
}

Also worth mentioning might be that I only own personal team account

Hello @VadamC, thank you for your post. The Spatial Audio options in Control Center are available when playing back media using AVPlayer or AVSampleBufferAudioRenderer. Using AVAudioEnvironmentNode will not enable the Spatial Audio options in Control Center, but that doesn't mean audio is not spatial.

First approach uses AVAudioEnvironmentNode with AVAudioEngine. Chaining position of player as well as changing listener’s doesn’t seem to change anything in how audio plays.

I believe this is happening because you are connecting your player node to your environment node using a mono format. Please see the code below for a simple example:

import AVFoundation

class Player {
    let audioEngine = AVAudioEngine()
    let playerNode = AVAudioPlayerNode()
    let environmentNode = AVAudioEnvironmentNode()
    
    init(_ url: URL) throws {
        let audioFile = try AVAudioFile(forReading: url)
        let mono = AVAudioFormat(standardFormatWithSampleRate: audioFile.processingFormat.sampleRate, channels: 1)
        let stereo = AVAudioFormat(standardFormatWithSampleRate: audioFile.processingFormat.sampleRate, channels: 2)
        
        audioEngine.attach(playerNode)
        audioEngine.attach(environmentNode)
        audioEngine.connect(playerNode, to: environmentNode, format: mono)
        audioEngine.connect(environmentNode, to: audioEngine.mainMixerNode, format: stereo)
        audioEngine.prepare()
        try audioEngine.start()
        
        environmentNode.renderingAlgorithm = .HRTFHQ
        
        playerNode.pointSourceInHeadMode = .mono
        playerNode.position = AVAudio3DPoint(x: 0, y: 0, z: 10)
        playerNode.scheduleFile(audioFile, at: nil, completionHandler: nil)
        playerNode.play()
    }
    
    func updatePosition(_ angleInDegrees: Float) {
        environmentNode.listenerAngularOrientation = AVAudio3DAngularOrientation(yaw: angleInDegrees, pitch: 0, roll: 0)
    }
}

I've studied this thread and implemented Engineer's code, being careful to attach nodes to the AVAudioEnvironmentNode as mono. But the playback on my M3 MBP, and in my AirPod Pros, does not have that illusion of spatiality that I get from Atmos music. It's just stereo. If I move the source in only the y or z coordinates by setting .position on the mixer node that connects to the environment node, then I hear it get louder and quieter, but it's coming from the laptop keyboard all the time. There are other dead ends on this forum and on the internet as well. What is the magic bullet?

For completeness, this is the view I'm experimenting with:

import AVFoundation
import SwiftUI

class EngineerPlayer {
  let audioEngine = AVAudioEngine()
  let playerNode = AVAudioPlayerNode()
  let environmentNode = AVAudioEnvironmentNode()
  
  init(_ url: URL) throws {
    let audioFile = try AVAudioFile(forReading: url)
    let mono = AVAudioFormat(standardFormatWithSampleRate: audioFile.processingFormat.sampleRate, channels: 1)
    let stereo = AVAudioFormat(standardFormatWithSampleRate: audioFile.processingFormat.sampleRate, channels: 2)
    
    audioEngine.attach(playerNode)
    audioEngine.attach(environmentNode)
    audioEngine.connect(playerNode, to: environmentNode, format: mono)
    audioEngine.connect(environmentNode, to: audioEngine.mainMixerNode, format: stereo)
    audioEngine.prepare()
    try audioEngine.start()
    
    environmentNode.renderingAlgorithm = .HRTFHQ
    
    playerNode.pointSourceInHeadMode = .mono
    playerNode.position = AVAudio3DPoint(x: 0, y: 2, z: 10)
    playerNode.scheduleFile(audioFile, at: nil, completionHandler: nil)
  }
  
  func updatePosition(_ angleInDegrees: Float) {
    environmentNode.listenerAngularOrientation = AVAudio3DAngularOrientation(yaw: angleInDegrees, pitch: 0, roll: 0)
  }
}

struct SpatialView: View {
  var player: EngineerPlayer
  
  init() {
    do {
      try player = EngineerPlayer(Bundle.main.url(forResource: "beat", withExtension: "aiff")!)
    } catch {
      fatalError("error with aiff")
    }
  }
  
  var body: some View {
    Button("Start") {
      player.playerNode.play()
    }
    Button("Move it") {
      player.playerNode.position.y -= 0.5
    }
    Button("Move it back") {
      player.playerNode.position.y = 2.0
    }
  }
}

#Preview {
  SpatialView()
}

It's copied and pasted from Engineer, except I only call play() when the button is tapped. Tapping "Move it" repeatedly brings the source closer in the y direction and then eventually farther away to the rear. But I only hear it get louder then quieter, coming out of my keyboard. So the AVAudioEnvironmentNode is modeling in 3D and doing the right computation, but it's not rendered using psychoacoustics to provide a 3D effect.

Settings I have turned on include the Head Pose capability, Spatial Audio Profile capability, and Background Mode for audio.

I set my App Category to "Games - Music Games" and I now have psychoacoustic spatialization out of my MacBook speakers as well as AirPod Pro. This is terrific, but my app is not a game, so I'd love a better solution. Maybe there's something I can set on AVAudioSession.sharedInstance?

Spatial Audio on iOS 18 don't work as inteneded
 
 
Q