Web audio 空间化基础
正如大量的各种声音处理(或者其他)选择是不够的,WebAduioAPI 也包含了一些工具,可以让你模仿听众在声源周围移动时的声音差异,例如当你在 3D 游戏声源中移动时声音的平移。官方名词称为 空间化,这篇文章将会介绍如何实现这样一个系统的基础知识。
空间化的基础知识
在 Web Audio 中,复杂的 3D 空间化是使用 PannerNode
创建的,用外行人的话来说就是一个使音频出现在 3D 空间中的很酷的数学。想象一下声音从你头上飞过,爬到你身后,在你面前移动。诸如此类的事情。
它对 WebXR 和游戏非常有用。在 3D 空间中,它是实现逼真的音频效果的唯一方式。像 three.js 和 A-frame 这样的库在处理声音时就利用了它的潜力。值得注意的是,你不必在完整的 3D 空间中移动声音 - 你可以只使用 2D 平面,因此如果你计划实现一个 2D 游戏,这依然是你要寻找的节点。
备注:
还有一个设计用于处理创建简单的左右立体声平移效果的 StereoPannerNode
。这使用起来更简单,但显然无处可用。如果你只想要一个简单的立体声平移效果,我们的 StereoPannerNode 示例(请参阅源码)应该可以为你提供所需的一切。
3D boombox 演示
为了演示 3D 空间化,我们在 Using the Web Audio API 指南中的 boombox 演示的基础上创建一个修改版本。参见 3D spatialization demo live (同时也可以看 source code)
音箱放置于房间中(由浏览器视区边缘定义),在本 demo 中我们可以通过提供的控件移动和旋转它。当我们移动音箱时,它产生的声音会相应的改变,当它在移动到房间的左侧或右侧时声音平移,或当它远离用户时变得安静,或旋转使得扬声器背离它们等。这是通过给 PannerNode
对象实例设置不同的与该运动有关的属性来实现模拟空间化。
备注: 如果你使用耳机或者其他某种环绕声系统连接计算机,体验会更好。
创建 audio 收听者
让我们开始! BaseAudioContext
( AudioContext
扩展自该接口)有一个 listener 属性,返回一个 AudioListener
对象。这代表着场景收听者,通常是使用者(用户)。你可以定义他 (她) 们在空间中的位置和他 (她) 们面向的方向。他 (她) 们保持静止。 pannerNode
可以计算出声音相对于收听者位置的位置。
让我们创建我们的上下文和监听器,并设置收听者的位置来模拟一个看向(探索)我们房间的人:
const AudioContext = window.AudioContext || window.webkitAudioContext;
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 属性代表了收听者前进方向的 3D 坐标位置(例如他/她们面向的方向),而 up 属性表示了收听者头顶的 3D 坐标位置。这两种属性值可以很好的设定方位。
创建 panner 节点
让我们创建 PannerNode
节点,这有很多与之相关的属性。让我们来一一看看:
首先我们可以设置 panningModel
。这是用于在 3D 空间中定位音频的空间化算法。我们可以将其设置为:
equalpower
— 计算出默认和一般方式的平移。
HRTF
— 这代表 'Head-related transfer function' ,在弄清楚声音的位置时,会考虑人脑(对声音的处理)。
非常聪明的东西,让我们使用 HRTF
模型!
const pannerModel = "HRTF";
属性 coneInnerAngle
和 coneOuterAngle
指定音量发出的位置。默认情况下,两者都是 360 度。我们可以定义音箱扬声器拥有较小的锥体。内锥是总是模拟增益(音量)最大值的地方,外锥是增益开始下降的地方。
增益通过 coneOuterGain
值来减少。让我们创建之后将会用于这些参数的常量:
const innerCone = 60;
const outerCone = 90;
const outerGain = 0.3;
下一个参数是 distanceModel
— 这只能设置为 linear
, inverse
, 或者 exponential
。这些是不同的算法,用于在音频源远离收听者时减小音频源的音量。
我们将使用linear
,因为它很简单:
const distanceModel = "linear";
我们可以设置源和收听者之间的最大距离 (maxDistance
) — 如果源距离此点更远,则音量将不再减小。这可能很有用,因为你可能会发现你想要模拟距离,但是音量会下降,而实际上并不是你想要的。默认情况下,它是 10,000(无单位的相对值)。我们可以像这样保持它:
const maxDistance = 10000;
还有一个参考距离 (refDistance
),由距离模型使用。我们也可以将其保持为默认值 1
:
const refDistance = 1;
然后就是 roll-off 因子 (rolloffFactor
) — 描述随着 panner 远离收听者,音量减小的速度有多快。默认值为 1;让我们使其大一些以放大我们的动作。
const rollOff = 10;
现在我们可以开始设置我们 boombox 的位置和方向。这与我们如何设置收听者很像。 这些也是我们在使用界面上的控件时要改变的参数。
const positionX = posX;
const positionY = posY;
const positionZ = posZ;
const orientationX = 0.0;
const orientationY = 0.0;
const orientationZ = -1.0;
注意 z 方向的负值 - 这会将 boombox 设置为面向我们。正值会使声源背离我们。 让我们使用相关的构造函数来创建我们的 panner 节点,并传入我们在上面设置的所有参数:
const panner = new PannerNode(audioCtx, {
panningModel: pannerModel,
distanceModel: distanceModel,
positionX: positionX,
positionY: positionY,
positionZ: positionZ,
orientationX: orientationX,
orientationY: orientationY,
orientationZ: orientationZ,
refDistance: refDistance,
maxDistance: maxDistance,
rolloffFactor: rollOff,
coneInnerAngle: innerCone,
coneOuterAngle: outerCone,
coneOuterGain: outerGain,
});
移动 boombox
现在我们将在我们的“房间”中四处移动 boombox。我们已经设置了一些控件来执行此操作。我们可以左右移动,上下移动,来回移动;我们也可以旋转它。声音方向来自前面的 boombox 的扬声器,因此当我们旋转它时,我们可以改变声音的方向 - 即当音箱旋转 180 度并背向我们时,使其向后投射。 我们需要为界面设置一些东西。首先,我们将获得我们想要移动的元素的引用,然后存储我们在设置 CSS transforms 来实际执行移动时将要更改的值的引用。 最后,我们将设置一些边界值,以便我们的 boombox 在任何方向上都不会走得太远:
const moveControls = document
.querySelector("#move-controls")
.querySelectorAll("button");
const boombox = document.querySelector(".boombox-body");
// the values for our css transforms
let 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 变换及更新我们的 panner 节点的位置和方向属性值以适当地更改声音。 首先让我们看看左,右,上,下值,因为这些非常简单。我们将沿着这些轴移动 boombox,并更新合适的位置。
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
中使用,当我们在旋转我们的 boombox,需要计算出新的坐标时:
// 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;
我们以左旋转为例看一看。我们需要更改 panner 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 来帮助我们计算出旋转 boombox 所需的圆周运动的坐标。 我们可以为所有轴做到这一点。只需要选择正确的方向进行更新,以及我们是想要正增量还是负增量。
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(function (el) {
let moving;
el.addEventListener(
"mousedown",
function () {
let direction = this.dataset.control;
if (moving && moving.frameId) {
window.cancelAnimationFrame(moving.frameId);
}
moving = moveBoombox(direction);
},
false,
);
window.addEventListener(
"mouseup",
function () {
if (moving && moving.frameId) {
window.cancelAnimationFrame(moving.frameId);
}
},
false,
);
});
概述
希望本文能让你深入了解 Web Audio 空间化的工作原理,以及每个PannerNode
属性的作用(其中有很多属性)。这些值有时难以操作,根据你的使用情况,可能需要一些时间才能使它们正确。
备注: 音频空间化在不同浏览器中的听起来略有不同。panner 节点在底层做了一些非常复杂的数学运算;这里有 许多测试,因此你可以跟踪不同平台上此节点的内部工作状态。
再次,你可以在 这里查看最终的演示,同时最终的源代码在这里。还有一个 Codepen 的演示。
如果你正在使用 3D 游戏和/或 WebXR,最好利用 3D 库来创建此类功能,而不是尝试从最初的规则完成所有这些操作。我们在本文中提出了自己的想法,让你了解它是如何工作的,但是通过利用别人在你之前所做的工作,你将节省大量时间。