空间音频
ᯅ Apple Vision Pro 发售,沉浸式体验再上一台阶,是时候为你的 visionOS App 和游戏适配一下空间音频效果了。
空间音频(Spatial Audio)是一种音频技术,通过添加高级编码和信号处理技术,模拟和再现三维空间中的声音位置和环境,提供更加沉浸和逼真的听觉体验。它使听众能够感受到声音的方向、距离和运动,从而创造出更加真实和身临其境的听觉环境。
空间音频广泛应用于各种领域,包括音乐制作、电影、游戏和虚拟现实(VR)等。它提供了更加沉浸和逼真的听觉体验,使用户能够更好地感受到环境音效、音乐和声音效果的立体和真实感。
有 AirPods 的同学可能已经体验过相应的效果,特别是开启了空间音频后,左右转头时会有,音源是固定在真实空间的感受。
通过阅读本文,咱们将会:
- 详细了解苹果的空间音频开发框架 PHASE
- 利用 SceneKit 来实现一个空间位置的可视化功能
- 实现一个空间音频的 Demo,感受音频在你耳边环绕的感觉
PHASE
PHASE 就是一个苹果提供的用来开发空间音频的框架。
先看官方文档,来个大致了解:
使用PHASE(物理音频空间化引擎)为您的游戏和应用提供复杂而动态的音频体验。通过PHASE,您可以实时控制声音层并调整音频参数。在开发应用程序时,与应用程序的视觉场景的动态集成使音频能够自动响应逻辑和视觉变化。该框架支持各种音频硬件,使您的应用程序能够在各平台和耳机、扬声器等输出设备上提供一致的空间音频体验。
PHASE将声音与视觉效果相结合,并通过以下方式最大限度地减少应用的音频维护:
- 接受场景几何信息,并降低被阻挡的发声场景对象的音量。例如,当玩家躲在墙后时,PHASE会降低飞来的火球的音量。
- 提供根据应用程序运行时状态播放的复杂声音事件。例如,当玩家走在草地上或者沙砾上时,应当发出不一样的声音
- 添加从形状发出的音效。当您向PHASE提供场景对象的形状时,声音的音量会根据玩家与形状的距离和方向进行调整。例如,给一条河添加体积音源,这样就不需要通过多个点音源来模拟一条河发出的水流声音。
- 添加混响和定时音频反射,以创建环境效果并模拟室内场景。例如,在大房间或者小房间时,声音的混响和反射是不同的。
如何做?
咱们先不着急一头扎直接扎进文档堆里,先来还原声音本来的样子,设想一下:你头戴 Apple Vision Pro,坐在沙发上,想声临其境的听一支乐队为你演奏。如果我们尝试建模这样一个场景,并且梳理出来有哪些因素会影响到你欣赏音乐的。
- 收听者:你所在的位置和朝向都会影响到收听的效果
- 音源:放置的位置、朝向,音源形状等
- 可能存在的遮挡:音源与收听者之前是否有遮挡?遮挡特的材料影响,是墙?还是木门?
- 房间的大小会影响混响
这是一个比较简单的场景,PHASE 通过构建一个虚拟房间, 设置一个虚拟的听众, 按照真实的位置来放置乐器音源 ,如果你佩戴了 Airpods 来收听这场音乐会,当你左右转头的时候,你会感觉乐器的音源 是放在那个固定的位置,如果我们实时的调整音源位置的变化,你会感受到声音在运动,这就是空间音乐。
当然还有更复杂的影响因素,后面再讲,下面介绍一个官方文档内容
概念
下面几个就是我们上面说到真实场景中几个对象的抽象,比较好理解。
PHASEListener
一个用于定义在场景中对用户最易听到的位置的中心参考点。
PHASESource
一个从场景中的3D位置和方向播放音频的对象,由 3D transform 来定位和定向。可以是一个发声点或区域,可以有形状或者体积。通过使用 PHASEShape 来表示 3D 体积。
PHASEOccluder
一个具有形状和位置的对象,阻止音频达到听众的位置。声音通过个位置时,会被降低音量。在我们说的例子中并未使用,但在游戏中较为常用。同样通过使用 PHASEShape 来表示 3D 体积。
Sound Event Nodes
Sound Event 代表一个逻辑层次结构,它定义框架在运行时播放声音的内容 、时间 和方式 。简单来说就是,我的通过 Sound Event 来告诉引擎,放什么和怎么放,所以主要分成两种节点:Audio-Providing Nodes 、Control Nodes。
其中 Audio-Providing Nodes 由 PHASESamplerNodeDefinition 来定义完整音频、由PHASEPushStreamNodeDefinition 来定义音频流,同时也可以设置播放模式、剔除选项、响度校正。
Control Nodes 有这几种:PHASESwitch NodeDefinition、PHASERandom NodeDefinition、PHASEBlend NodeDefinition、PHASEContainerNodeDefinition,如下:
Sound Event Tree
这个可以借用官方 Session 里的一张图来解释:一个 Sound Event 的逻辑层次结构,从图中就可以看到上面说到过的所有 Node。
Mixer
Mixer 是来决定音频分层和效果的,有三个类型
-
PHASEChannelMixerDefinition:这个是直接将声音输出,不考虑效果,比如:菜单的点击音效。
-
PHASEAmbientMixerDefinition:定义在 3D 空间中的特定方向输出声音的音频分层。
-
PHASESpatialMixerDefinition:定义在 3D 场景中,跟据环境特征来决定音频效果。
这是重点说一下 PHASESpatialMixerDefinition,PHASESpatialMixerDefinition 有三种方式会影响到声音效果,分别是: Sound Reflections and Resonance (声音的反射和共鸣)、Distance Modeling (距离建模,声音近大远小)、Sound Directivity (声音指向性,想象一个扬声器,它正对着你的时候声音大)。
PHASEEngine
是时候来了解引擎了,PHASEEngine 是用来管理音频资源、控制播放和配置环境效果的。按照官方文档将基功能分为:
- Creating an Engine:创建引擎,可以配置更新规则。
- Registering Audio Resources:加载和卸载音频资源的对象,完整音频需要提前注册、Sound Event 也需要提前注册。
- Accessing Scene Hierarchy:访问场景层次结构,像上面提到的 Listener、Source、Occluder 的位置关系。
- Defining Environmental Effects:决定声音的共鸣环境、传播的物理材质、创建 3D 声音体验的模式。
- Controlling and Inspecting Playback State:控制和检查播放状态,这是播放器都有的,如:暂停、播放等。
- Managing Groups of Sounds:管理声音组的,有一些声音会应用相同配置,会定义出声音组。
- Accessing In-Flight Audio:在各种运行时环境下播放的声音的集合。
- Measuring Units:时间和距离的单位转换,统一标准使用。
Demo
我们将做一个通过摇杆来控制的飞机,飞机可以在你的周围飞行,使用 PHASE 来播放音频,这样就可以感受一下飞机在你耳边环绕飞行的空间音频体验。
准备工作
因为涉及到 3D 位置同时还有小飞机,所以我们可以基于 SceneKit 初始工程修改就可以。PHASE 不挑平台,非常独立,可以基于 visionOS、iOS、iPadOS 等,也不挑与之配合的技术栈,可以配合 UIKit、SwiftUI、RealityKit、SceneKit 等一起开发。
- 创建 SceneKit 游戏工程
- 强制横屏,并运行程序,就会发现有一个飞机在旋转,我们将使用这个飞来实现我们的 Demo。
- 我们对工程做简单修改,在 GameViewController 中将摄像头视角调整为从正上方往下看,并移除旋转相关代码。
scss
// create and add a camera to the scene
let cameraNode = SCNNode()
cameraNode.camera = SCNCamera()
scene.rootNode.addChildNode(cameraNode)
// place the camera
cameraNode.position = SCNVector3(x: 0, y: 0, z: 15)
// 将以上代码修改为
// create and add a camera to the scene
let cameraNode = SCNNode()
cameraNode.camera = SCNCamera()
cameraNode.camera?.usesOrthographicProjection = true
cameraNode.camera?.orthographicScale = 20
scene.rootNode.addChildNode(cameraNode)
// place the camera
cameraNode.position = SCNVector3(x: 0, y: 15, z: 0)
cameraNode.eulerAngles = SCNVector3(-Float.pi / 2, 0, 0)
arduino
// 注释此行代码
// animate the 3d object
// ship.runAction(SCNAction.repeatForever(SCNAction.rotateBy(x: 0, y: 2, z: 0, duration: 1)))
// 将 allowsCameraControl 修改为 false
// allows the user to manipulate the camera
scnView.allowsCameraControl = false
完成后效果如下:
- 使用 SPM 添加一个开源的摇杆控件 github.com/michael94el... , 并在页面上添加控件
swift
// 因为控件是 SwiftUI 控件,我们桥接一下
import SwiftUI
import SwiftUIJoystick
public struct Joystick: View {
@ObservedObject public var joystickMonitor: JoystickMonitor
private let dragDiameter: CGFloat
private let shape: JoystickShape
public init(monitor: JoystickMonitor, width: CGFloat, shape: JoystickShape = .circle) {
self.joystickMonitor = monitor
self.dragDiameter = width
self.shape = shape
}
public var body: some View {
VStack{
JoystickBuilder(
monitor: self.joystickMonitor,
width: self.dragDiameter,
shape: .circle,
background: {
// Example Background
Circle().fill(Color.blue.opacity(0.9))
.frame(width: dragDiameter, height: dragDiameter)
},
foreground: {
// Example Thumb
Circle().fill(Color.black)
.frame(width: 20, height: 20)
},
locksInPlace: true)
}
}
}
添加 GameViewController 中添加如下代码
ini
@ObservedObject var monitor = JoystickMonitor()
我们在 viewDidLoad 里添加摇杆
less
let joyStick = Joystick(monitor: monitor, width: 150, shape: .circle)
let hostingController = UIHostingController(rootView: joyStick)
view.addSubview(hostingController.view)
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -70),
hostingController.view.centerXAnchor.constraint(equalTo: view.centerXAnchor),
hostingController.view.widthAnchor.constraint(equalToConstant: 150),
hostingController.view.heightAnchor.constraint(equalToConstant: 150)
])
hostingController.didMove(toParent: self)
运行代码:
- 完成摇杆来操作飞机
ini
// 作法有点粗糙,但这不是重点
cancel = monitor.objectWillChange.sink { _ in
let x = self.monitor.xyPoint.x / 10
let z = self.monitor.xyPoint.y / 10
// 创建旋转矩阵
// 计算从 p1 到 p2 的方向向量
let p1 = ship.position
let p2 = SCNVector3(x, 0, z)
let directionVector = SCNVector3(p2.x - p1.x, p2.y - p1.y, p2.z - p1.z)
// 计算旋转角度和轴
let angle = atan2(directionVector.x, directionVector.z)
let rotationAxis = SCNVector3(0, 1, 0)
let rotationMatrix = SCNMatrix4MakeRotation(Float(angle), rotationAxis.x, rotationAxis.y, rotationAxis.z)
// 创建平移矩阵
let translationMatrix = SCNMatrix4MakeTranslation(Float(x), 0, Float(z))
ship.transform = SCNMatrix4Mult(rotationMatrix, translationMatrix)
}
- 如果不想处理这块代码可以下载:初始化工程
给小飞机配个空间音频
配置 PHASEEngine
和 环境
swift
import PHASE
class GameViewController : UIViewController {
// PHASE
var engine: PHASEEngine!
var listener: PHASEListener!
var source: PHASESource!
override func viewDidLoad() {
super.viewDidLoad()
// 配置 PHASE 相关
configEngine()
// other code
}
func configEngine() {
self.phaseEngine = PhaseEngine(updateMode: .automatic)
self.engine.defaultReverbPreset = .largeRoom
try? self.engine.start()
}
}
创建一个能听到声音的对象,并设置位置
PHASEListener
是应用中听到声音的对象;它是用户体验空间音频的中心位置和方向。该框架根据声源相对于听众的独特位置和方向来调整声源的音量。例如,在距离听者较远的地方播放的声音会安静地播放,而距离听者较近的声音会播放得更大声
swift
func configEngine() {
// *****
// Listener
self.listener = PHASEListener(engine: self.engine)
// 设置收听者的位置
self.listener.transform = matrix_identity_float4x4
// 添加到场景中
try? self.engine.rootObject.addChild(listener)
}
创建声源和设置声源位置
PHASESource
是应用中发出声音的对象;后面我们将通过设置 transform 来定义声源在场景中的位置变换。
php
func configEngine() {
// *****
// 设置播放源 Source
// 播放源形状
let mesh = MDLMesh.newIcosahedron(withRadius: 0.0142, inwardNormals: false, allocator:nil)
let shape = PHASEShape(engine: engine, mesh: mesh)
let source = PHASESource(engine: engine, shapes: [shape])
// 设置播放源位置,
source.transform = matrix_identity_float4x4
// 添加到场景中
try? engine.rootObject.addChild(source)
}
描述输出管道
作为音频播放配置的最后步骤之一,应用指定特定的对象或混音器,用于组合相关的音频信号以传输到输出设备。对于空间音频,应用会创建一个空间混合器 PHASESpatialMixerDefinition
,除了directPathTransmission
之外,空间混音器还可以在 flags 参数中加入 earlyReflections 或 lateReverb 配置,从而为输出添加环境层,例如反射或混响。
ini
// 输出管道
let pipeline = PHASESpatialPipeline(flags: [.directPathTransmission, .lateReverb])!
pipeline.entries[PHASESpatialCategory.lateReverb]!.sendLevel = 0.1
配置根据距离调节音量
PHASE 通过观察您在空间混音器上定义的距离模型来衰减源和听众之间距离上的声音。当声源发出声音时,空间混音器会根据与听众的距离来调整其音量。声源距离听者越远,音量衰减得越多,声音相对于听者来说就越安静。如果没有配置相关距离模型,则会以恒定的音频播放声音。
ini
// 配置根据距离调节音量
// PHASEGeometricSpreadingDistanceModelParameters 模拟随距离的声音损失的模型
let distanceModelParameters = PHASEGeometricSpreadingDistanceModelParameters()
// 我们在控制了飞机在 15 米为半径的圆内飞行
// 此处设置超过 16 米,超过 16 米声音渐隐
distanceModelParameters.fadeOutParameters = PHASEDistanceModelFadeOutParameters(cullDistance: 16)
distanceModelParameters.rolloffFactor = 0.3
let spatialMixerDefinition = PHASESpatialMixerDefinition(spatialPipeline: pipeline)
spatialMixerDefinition.distanceModelParameters = distanceModelParameters
生成声音事件
前面是让 PHASE 了解场景和配置,接下来我们将使用 PHASESoundEvent
来播放和控制声音,这里就可以跟据自己的业务需要通过 PHASESoundEvent
来定制声音播放。
首先音频资源需要注册至资产表中,我已经在工程中添加了飞机的音源:plane.mp3
。
php
// 注册声音资源
let soundURL = Bundle.main.url(forResource: "plane", withExtension: "mp3")!
let soundAsset = try! self.engine.assetRegistry.registerSoundAsset(
url: soundURL,
identifier: "planeAsset",
assetType: .resident,
channelLayout: nil,
normalizationMode: .dynamic)
// 创建采样器节点
let samplerNodeDefinition = PHASESamplerNodeDefinition(
soundAssetIdentifier: soundAsset.identifier,
mixerDefinition: spatialMixerDefinition
)
samplerNodeDefinition.playbackMode = .looping
samplerNodeDefinition.setCalibrationMode(calibrationMode: .relativeSpl, level: 0)
samplerNodeDefinition.cullOption = .sleepWakeAtRealtimeOffset
// 向引擎注册声音事件节点的资产来提供有关声音事件事件信息
let planeSoundEventAsset = try! engine.assetRegistry.registerSoundEventAsset(
rootNode: samplerNodeDefinition,
identifier: soundAsset.identifier + "_SoundEventAsset"
)
// 通过为每个空间混音器配置 PHASEMixerParameters 对象来定义播放音频的声源以及收听音频的听者
let mixerParameters = PHASEMixerParameters()
mixerParameters.addSpatialMixerParameters(
identifier: spatialMixerDefinition.identifier,
source: source,
listener: listener
)
// 要播放声音,请为每个节点生成一个 PHASESoundEvent 实例,并在声音事件上调用 start(completion:)
// 通过 PHASESoundEvent 初始化器 mixerParameters 参数传递空间混合器参数,将源与声音事件关联起来
let planeSoundEvent = try! PHASESoundEvent(
engine: engine,
assetIdentifier: planeSoundEventAsset.identifier,
mixerParameters: mixerParameters
)
planeSoundEvent.start()
运行一下
你将会听到声音正常播放了,但控制了摇杆并不能控制声音,是因为我们还没有把声源的位置和飞机的位置关联
关联声源与飞机的位置,并试玩
scss
cancel = monitor.objectWillChange.sink { _ in
// *****
// 创建平移矩阵
let translationMatrix = SCNMatrix4MakeTranslation(Float(x), 0, Float(z))
ship.transform = SCNMatrix4Mult(rotationMatrix, translationMatrix)
// 关联音源 与 飞机的位置
self.source.transform = simd_float4x4(translationMatrix)
}
总结
本文介绍了 PHASE 的结构和使用方法,并实现了一个 Demo 代码在这. 如果要做进一步深度使用的话,还是需要再研究一下,比如混音的一些参数配置等, Event Tree 也值得研究研究。