就仿佛它丰富多样的声音处理(和其他)功能还不够一样,Web Audio API 还包含了一些功能,能让你模拟出收听者在声源周围移动时声音上的差异,例如在玩 3D
游戏时,当你围绕着一个声源移动时产生的声像定位效果。它的官方术语叫 “空间化”,本文将介绍如何实现这样一个系统的基础知识。
空间化基础
在网络音频中,复杂的 3D
空间化效果是通过 PannerNode
(声像定位器节点)来创建的,通俗来讲,它基本上就是运用大量很酷的数学运算,让音频能在 3D
空间中呈现出来。想象一下声音从你头顶飞过、悄悄溜到你身后、在你身前移动等情况。
它对 WebXR
(网络扩展现实)和游戏开发非常有用。在 3D
空间中,它是实现逼真音频效果的唯一途径。像 three.js 和 A-Frame 这样的库在处理声音时也会利用它的潜力。值得注意的是,你不一定要在完整的 3D
空间内移动声音 —— 你可以只局限在二维平面上操作,所以,如果你正计划开发一款 2D
游戏,这个节点依然会是你所需要的。
注:
还有一个StereoPannerNode
(立体声声像定位器节点),它旨在处理创建简单的左右立体声声像定位效果这一常见用例。这个节点使用起来要简单得多,但显然远没有那么多功能。如果你只是想要一个简单的立体声声像定位效果,StereoPannerNode 示例(查看源代码)应该能满足你的所有需求。
3D 便携式立体声音响示例
为了演示 3D
空间化效果,我们对在 使用Web Audio API 中创建的便携式立体声音响演示示例进行了修改。查看 实时的 3D 空间化演示(也可 查看源代码)。
便携式立体声音响位于一个房间内(由浏览器视口的边缘来界定),在这个演示中,我们可以通过提供的控制按钮来移动和旋转它。当我们移动这个便携式立体声音响时,它发出的声音会相应地发生变化,当它移到房间的左侧或右侧时会产生声像定位效果,或者当它被移得离使用者更远,或者被旋转至扬声器背对使用者时,声音就会变轻等等。这是通过根据相应的移动情况来设置 PannerNode
对象实例的不同属性,从而模拟出空间化效果来实现的。
注:
如果你使用耳机,或者将电脑接入某种环绕声系统,体验会更好。
创建音频收听者
那么让我们开始吧!BaseAudioContext
(AudioContext
继承自该接口)有一个 listener
属性,它会返回一个 AudioListener
对象。这个对象代表着场景中的收听者,通常就是你的用户。你可以定义他们在空间中的位置和朝向。他们是保持静止的。然后,pannerNode
就能根据收听者的位置来计算其声音的位置了。
让我们创建音频上下文和音频收听者,并设置收听者的位置,以模拟一个正朝我们房间里面看的人所处的位置:
const audioCtx = new AudioContext();
const listener = audioCtx.listener;
const posX = window.innerWidth / 2;
const posY = window.innerHeight / 2;
const posZ = 300;
listener.positionX.value = posX;
listener.positionY.value = posY;
listener.positionZ.value = posZ - 5;
我们可以使用 positionX
将收听者向左或向右移动,使用 positionY
将其向上或向下移动,或者使用 positionZ
使其移入或移出房间。在这里,我们将收听者的位置设置在视口的中间,且稍微位于我们的便携式立体声音响的前方。我们还可以设置收听者面朝的方向。这些属性的默认值效果就很不错:
listener.forwardX.value = 0;
listener.forwardY.value = 0;
listener.forwardZ.value = -1;
listener.upX.value = 0;
listener.upY.value = 1;
listener.upZ.value = 0;
forward
属性代表收听者面朝方向的三维坐标位置,而 up
属性代表收听者头顶的三维坐标位置。这两个属性结合起来就能很好地设置方向了。
创建声像定位器节点
让我们来创建 PannerNode
。它有一大堆与之相关的属性,我们来逐一看看这些属性吧!
首先,我们可以设置 panningModel
(声像定位模型)。这是用于在 3D
空间中定位音频的空间化算法。我们可以将其设置为:
equalpower
:默认且通用的声像定位计算方式;HRTF
:这代表 “头相关变换函数”(Head-related transfer function),在确定声音位置时会考虑到人的头部。
相当巧妙的设计。咱们就使用 HRTF
模型吧!
const panningModel = "HRTF";
coneInnerAngle
(圆锥内角度)和 coneOuterAngle
(圆锥外角度)属性指定了音量的发声范围。默认情况下,这两个角度都是 360
度。我们的便携式立体声音响的扬声器将具有较小的圆锥范围,我们可以对其进行定义。内圆锥区域是增益(音量)始终模拟为最大值的区域,而外圆锥区域则是增益开始下降的区域。增益会按照 coneOuterGain
(圆锥外增益)的值来降低。我们创建一些常量,用来存储稍后在这些参数中会用到的值:
const innerCone = 60;
const outerCone = 90;
const outerGain = 0.3;
下一个参数是 distanceModel
(距离模型)—— 它只能被设置为 linear
、inverse
或 exponential
模式。这些是不同的算法,用于在音频源远离收听者时降低其音量。我们将使用 linear
模式,因为它比较简单:
const distanceModel = "linear";
我们可以设置音频源与收听者之间的最大距离(maxDistance
)—— 如果音频源移动到超出这个距离点,音量就不会再降低了。这可能很有用,因为你可能会发现自己想要模拟距离效果,但音量可能会降得过低,而这实际上并非你想要的情况。默认情况下,该值为 10000
(一个无单位的相对值)。我们可以保持这个默认值不变:
const maxDistance = 10000;
还有一个参考距离(refDistance
),距离模型会用到这个参数。我们也可以将其保持为默认值 1
:
const refDistance = 1;
然后是衰减系数(rolloffFactor
)—— 当声像定位器节点远离收听者时,音量降低的速度有多快,默认值是 1
。我们把这个值设得稍大一点,以便突出我们所做的移动操作产生的效果。
const rollOff = 10;
现在我们可以开始设置便携式立体声音响的位置和方向了。这和我们设置收听者相关参数的方式很相似。而且,当使用界面上的控制按钮时,这些也正是我们将要改变的参数。
const positionX = posX;
const positionY = posY;
const positionZ = posZ;
const orientationX = 0.0;
const orientationY = 0.0;
const orientationZ = -1.0;
注意我们在 z
轴方向上的负值 —— 这会使便携式立体声音响面朝我们,正值则会使声源背朝我们。
让我们使用相关的构造函数来创建声像定位器节点,并传入上面设置的所有参数:
const panner = new PannerNode(audioCtx, {
panningModel,
distanceModel,
positionX,
positionY,
positionZ,
orientationX,
orientationY,
orientationZ,
refDistance,
maxDistance,
rolloffFactor: rollOff,
coneInnerAngle: innerCone,
coneOuterAngle: outerCone,
coneOuterGain: outerGain,
});
移动便携式立体声音响
现在我们要在 “房间” 里移动便携式立体声音响。我们已经设置了一些控件来实现这个操作。我们可以将它左右、上下以及前后移动,还可以旋转它。声音的方向是从音响正面的扬声器发出的,所以当我们旋转它时,我们可以改变声音的方向 —— 也就是说,当便携式立体声音响旋转 180
度且背朝我们时,声音就会朝后方传播。
我们需要为界面设置一些东西。首先,要获取那些我们想要移动的元素的引用,然后,当我们设置 CSS
变换来实际执行移动操作时,我们会存储那些将要改变的值的引用。最后,我们会设置一些边界限制,这样我们的便携式立体声音响就不会在任何方向上移动得太远:
const moveControls = document
.querySelector("#move-controls")
.querySelectorAll("button");
const boombox = document.querySelector(".boombox-body");
// the values for our CSS transforms
const transform = {
xAxis: 0,
yAxis: 0,
zAxis: 0.8,
rotateX: 0,
rotateY: 0,
};
// set our bounds
const topBound = -posY;
const bottomBound = posY;
const rightBound = posX;
const leftBound = -posX;
const innerBound = 0.1;
const outerBound = 1.5;
让我们创建一个函数,该函数将想要移动的方向作为参数,它既能修改 CSS
变换,又能更新我们声像定位器节点属性的位置和方向值,以便相应地改变声音。
首先,让我们来看一下左右、上下的值,因为这些都相当简单明了。我们将沿着这些坐标轴移动便携式立体声音响,并更新相应的位置。
function moveBoombox(direction) {
switch (direction) {
case "left":
if (transform.xAxis > leftBound) {
transform.xAxis -= 5;
panner.positionX.value -= 0.1;
}
break;
case "up":
if (transform.yAxis > topBound) {
transform.yAxis -= 5;
panner.positionY.value -= 0.3;
}
break;
case "right":
if (transform.xAxis < rightBound) {
transform.xAxis += 5;
panner.positionX.value += 0.1;
}
break;
case "down":
if (transform.yAxis < bottomBound) {
transform.yAxis += 5;
panner.positionY.value += 0.3;
}
break;
}
}
对于前后移动(移入和移出)的值来说,情况也是类似的:
case 'back':
if (transform.zAxis > innerBound) {
transform.zAxis -= 0.01;
panner.positionZ.value += 40;
}
break;
case 'forward':
if (transform.zAxis < outerBound) {
transform.zAxis += 0.01;
panner.positionZ.value -= 40;
}
break;
不过,旋转值就稍微复杂一些了,因为我们需要让声音绕着某个方向转动。我们不仅要更新两个坐标轴的值(例如,如果你绕着 x
轴旋转一个物体,你就得更新该物体的 y
坐标和 z
坐标),而且还需要为此进行更多的数学运算。旋转是呈圆周运动的,我们需要借助 Math.sin
和 Math.cos
来帮助我们描绘出这个圆周运动。
让我们设置一个旋转速率,之后当我们旋转便携式立体声音响并想要算出新坐标时,我们会将这个旋转速率转换为弧度范围值,以供在 Math.sin
和 Math.cos
中使用。
// set up rotation constants
const rotationRate = 60; // bigger number equals slower sound rotation
const q = Math.PI / rotationRate; //rotation increment in radians
我们也可以利用这个来算出旋转的角度,这对我们将要创建的 CSS
变换会有帮助(注意,对于 CSS
变换来说,我们需要同时考虑 x
轴和 y
轴):
// get degrees for CSS
const degreesX = (q * 180) / Math.PI;
const degreesY = (q * 180) / Math.PI;
让我们以向左旋转为例来看一下。我们需要改变声像定位器坐标的 x
方向和 z
方向,以便围绕 y
轴进行向左旋转操作:
case 'rotate-left':
transform.rotateY -= degreesY;
// 'left' is rotation about y-axis with negative angle increment
z = panner.orientationZ.value*Math.cos(q) - panner.orientationX.value*Math.sin(q);
x = panner.orientationZ.value*Math.sin(q) + panner.orientationX.value*Math.cos(q);
y = panner.orientationY.value;
panner.orientationX.value = x;
panner.orientationY.value = y;
panner.orientationZ.value = z;
break;
这有点令人困惑,但我们正在做的是利用 sin
和 cos
来帮助我们算出便携式立体声音响旋转时坐标所需的圆周运动情况。
我们可以针对所有坐标轴进行这样的操作。我们只需要选择要更新的正确方向,以及确定我们想要的是正增量还是负增量即可。
case 'rotate-right':
transform.rotateY += degreesY;
// 'right' is rotation about y-axis with positive angle increment
z = panner.orientationZ.value*Math.cos(-q) - panner.orientationX.value*Math.sin(-q);
x = panner.orientationZ.value*Math.sin(-q) + panner.orientationX.value*Math.cos(-q);
y = panner.orientationY.value;
panner.orientationX.value = x;
panner.orientationY.value = y;
panner.orientationZ.value = z;
break;
case 'rotate-up':
transform.rotateX += degreesX;
// 'up' is rotation about x-axis with negative angle increment
z = panner.orientationZ.value*Math.cos(-q) - panner.orientationY.value*Math.sin(-q);
y = panner.orientationZ.value*Math.sin(-q) + panner.orientationY.value*Math.cos(-q);
x = panner.orientationX.value;
panner.orientationX.value = x;
panner.orientationY.value = y;
panner.orientationZ.value = z;
break;
case 'rotate-down':
transform.rotateX -= degreesX;
// 'down' is rotation about x-axis with positive angle increment
z = panner.orientationZ.value*Math.cos(q) - panner.orientationY.value*Math.sin(q);
y = panner.orientationZ.value*Math.sin(q) + panner.orientationY.value*Math.cos(q);
x = panner.orientationX.value;
panner.orientationX.value = x;
panner.orientationY.value = y;
panner.orientationZ.value = z;
break;
最后一件事 —— 我们需要更新 CSS
样式,并为鼠标事件保留上一次移动的记录。下面是最终的 moveBoombox
函数。
function moveBoombox(direction, prevMove) {
switch (direction) {
case "left":
if (transform.xAxis > leftBound) {
transform.xAxis -= 5;
panner.positionX.value -= 0.1;
}
break;
case "up":
if (transform.yAxis > topBound) {
transform.yAxis -= 5;
panner.positionY.value -= 0.3;
}
break;
case "right":
if (transform.xAxis < rightBound) {
transform.xAxis += 5;
panner.positionX.value += 0.1;
}
break;
case "down":
if (transform.yAxis < bottomBound) {
transform.yAxis += 5;
panner.positionY.value += 0.3;
}
break;
case "back":
if (transform.zAxis > innerBound) {
transform.zAxis -= 0.01;
panner.positionZ.value += 40;
}
break;
case "forward":
if (transform.zAxis < outerBound) {
transform.zAxis += 0.01;
panner.positionZ.value -= 40;
}
break;
case "rotate-left":
transform.rotateY -= degreesY;
// 'left' is rotation about y-axis with negative angle increment
z =
panner.orientationZ.value * Math.cos(q) -
panner.orientationX.value * Math.sin(q);
x =
panner.orientationZ.value * Math.sin(q) +
panner.orientationX.value * Math.cos(q);
y = panner.orientationY.value;
panner.orientationX.value = x;
panner.orientationY.value = y;
panner.orientationZ.value = z;
break;
case "rotate-right":
transform.rotateY += degreesY;
// 'right' is rotation about y-axis with positive angle increment
z =
panner.orientationZ.value * Math.cos(-q) -
panner.orientationX.value * Math.sin(-q);
x =
panner.orientationZ.value * Math.sin(-q) +
panner.orientationX.value * Math.cos(-q);
y = panner.orientationY.value;
panner.orientationX.value = x;
panner.orientationY.value = y;
panner.orientationZ.value = z;
break;
case "rotate-up":
transform.rotateX += degreesX;
// 'up' is rotation about x-axis with negative angle increment
z =
panner.orientationZ.value * Math.cos(-q) -
panner.orientationY.value * Math.sin(-q);
y =
panner.orientationZ.value * Math.sin(-q) +
panner.orientationY.value * Math.cos(-q);
x = panner.orientationX.value;
panner.orientationX.value = x;
panner.orientationY.value = y;
panner.orientationZ.value = z;
break;
case "rotate-down":
transform.rotateX -= degreesX;
// 'down' is rotation about x-axis with positive angle increment
z =
panner.orientationZ.value * Math.cos(q) -
panner.orientationY.value * Math.sin(q);
y =
panner.orientationZ.value * Math.sin(q) +
panner.orientationY.value * Math.cos(q);
x = panner.orientationX.value;
panner.orientationX.value = x;
panner.orientationY.value = y;
panner.orientationZ.value = z;
break;
}
boombox.style.transform =
`translateX(${transform.xAxis}px) ` +
`translateY(${transform.yAxis}px) ` +
`scale(${transform.zAxis}) ` +
`rotateY(${transform.rotateY}deg) ` +
`rotateX(${transform.rotateX}deg)`;
const move = prevMove || {};
move.frameId = requestAnimationFrame(() => moveBoombox(direction, move));
return move;
}
连接控制部件
连接控制按钮相对来说比较简单 —— 现在我们可以监听控制按钮上的鼠标事件并运行这个函数,而且在鼠标松开时停止该函数的运行:
// for each of our controls, move the boombox and change the position values
moveControls.forEach((el) => {
let moving;
el.addEventListener(
"mousedown",
() => {
const direction = this.dataset.control;
if (moving && moving.frameId) {
cancelAnimationFrame(moving.frameId);
}
moving = moveBoombox(direction);
},
false,
);
window.addEventListener(
"mouseup",
() => {
if (moving && moving.frameId) {
cancelAnimationFrame(moving.frameId);
}
},
false,
);
});
连接音频图
我们的 HTML
页面包含了那个我们希望受声像定位器节点影响的音频元素。
<audio src="myCoolTrack.mp3"></audio>
我们需要从该元素中获取音频源,并使用 AudioContext.createMediaElementSource
方法将其接入 Web Audio API 的链路中。
// get the audio element
const audioElement = document.querySelector("audio");
// pass it into the audio context
const track = audioContext.createMediaElementSource(audioElement);
接下来,我们必须连接音频链路。我们要将输入(音频轨道)连接到修改节点(声像定位器),再连接到输出端(在这种情况下就是扬声器)。
track.connect(panner).connect(audioCtx.destination);
让我们创建一个播放按钮,点击该按钮时,它会根据当前状态来播放或暂停音频。
<button data-playing="false" role="switch">Play/Pause</button>
// Select our play button
const playButton = document.querySelector("button");
playButton.addEventListener(
"click",
() => {
// Check if context is in suspended state (autoplay policy)
if (audioContext.state === "suspended") {
audioContext.resume();
}
// Play or pause track depending on state
if (playButton.dataset.playing === "false") {
audioElement.play();
playButton.dataset.playing = "true";
} else if (playButton.dataset.playing === "true") {
audioElement.pause();
playButton.dataset.playing = "false";
}
},
false,
);
如需更深入地了解音频播放 / 控制以及音频链路相关内容,请查看 使用Web Audio API。
总结
希望这篇文章能让你深入了解网络音频空间化是如何运作的,以及 PannerNode
的各个属性的作用(其属性数量还挺多的)。这些属性值有时候可能很难操控,而且根据你的使用场景,要把它们设置正确可能得花些时间。
注:
不同浏览器中音频空间化的音效呈现方式存在细微差异。声像定位器节点在底层进行着一些颇为复杂的数学运算;这里有 多项测试,这样你就能追踪该节点在不同浏览器的内部运行情况了。
再次强调,你可以在此处查看最终的演示示例,最终的源代码在这里。此外,还有一个 CodePen 上的演示示例。
如果你正在开发 3D
游戏和 / 或从事 WebXR
(网络虚拟现实)相关工作,利用 3D
库来创建此类功能是个不错的主意,而不是试图完全从最基本的原理开始自行构建。在本文中,我们自行构建相关功能是为了让你了解它的工作原理,但利用前人已完成的成果能帮你节省大量时间。
评论0
暂时没有评论