Web 音频空间化基础

仿佛其丰富的音频处理(以及其他)选项还不够,Web 音频 API 还包括允许您模拟监听器围绕声源移动时声音差异的功能,例如在 3D 游戏中围绕声源移动时的声场效果。此功能的正式术语是 **空间化**,本文将介绍如何实现此类系统的基础知识。

空间化的基础知识

在 Web 音频中,复杂的 3D 空间化是使用 PannerNode 创建的,用通俗的话来说,它基本上是一堆很酷的数学运算,可以让音频出现在 3D 空间中。想象一下声音从你头顶飞过,从你身后慢慢靠近,从你面前移动过去。诸如此类。

它对于 WebXR 和游戏非常有用。在 3D 空间中,这是实现真实音频的唯一方法。像 three.jsA-frame 这样的库在处理声音时利用了它的潜力。值得注意的是,您也不必在完整的 3D 空间中移动声音——您可以只使用 2D 平面,因此如果您正在计划一个 2D 游戏,这仍然是您要查找的节点。

注意:还有一个 StereoPannerNode 用于处理创建简单的左右立体声声场效果的常见用例。它使用起来简单得多,但显然没有那么通用。如果您只需要简单的立体声声场效果,我们的 StereoPannerNode 示例 (查看源代码) 应该可以为您提供所需的一切。

3D 录音机演示

为了演示 3D 空间化,我们创建了我们在基本 使用 Web 音频 API 指南中创建的录音机演示的修改版本。请参阅 3D 空间化演示(并查看 源代码)。

A simple UI with a rotated boombox and controls to move it left and right and in and out, and rotate it.

录音机位于房间内(由浏览器视口边缘定义),在此演示中,我们可以使用提供的控件移动和旋转它。当我们移动录音机时,它产生的声音会相应地发生变化,当它移到房间的左侧或右侧时会进行声场处理,或者当它远离用户移动或旋转扬声器使其背对用户时会变得更安静,等等。这是通过设置 PannerNode 对象实例的不同属性来实现的,这些属性与该移动相关,以模拟空间化。

注意:如果您使用耳机或将计算机插入某种环绕声系统,体验会更好。

创建音频监听器

因此,让我们开始吧!BaseAudioContextAudioContext 从其扩展的接口)有一个 listener 属性,它返回一个 AudioListener 对象。这表示场景的监听器,通常是您的用户。您可以定义他们在空间中的位置以及他们面对的方向。它们保持静止。然后,pannerNode 可以计算其相对于监听器位置的声源位置。

让我们创建我们的上下文和监听器,并将监听器的位置设置为模拟一个人正在查看我们的房间

js
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 进出房间。在这里,我们将监听器设置为位于视口中间,并且略微位于我们的录音机前方。我们还可以设置监听器面对的方向。这些的默认值效果很好

js
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 坐标位置。这两个属性可以很好地设置方向。

创建声场节点

让我们创建我们的 PannerNode。它有一堆相关的属性。让我们看看每个属性

首先,我们可以设置 panningModel。这是用于在 3D 空间中定位音频的空间化算法。我们可以将其设置为

equalpower - 默认值,也是计算声场处理的常用方法

HRTF - 代表“头部相关传递函数”,它试图在计算声音位置时考虑人类头部。

非常巧妙。让我们使用 HRTF 模型!

js
const panningModel = "HRTF";

coneInnerAngleconeOuterAngle 属性指定音量从何处发出。默认情况下,两者均为 360 度。我们的录音机扬声器将具有较小的圆锥体,我们可以定义它。内圆锥是始终以最大值模拟增益(音量)的位置,外圆锥是增益开始下降的位置。增益会根据 coneOuterGain 的值降低。让我们创建一些常量,稍后在这些参数中使用它们的值

js
const innerCone = 60;
const outerCone = 90;
const outerGain = 0.3;

下一个参数是 distanceModel - 它只能设置为 linearinverseexponential。这些是不同的算法,用于在声源远离监听器时降低音量。我们将使用 linear,因为它很简单

js
const distanceModel = "linear";

我们可以设置声源和监听器之间的最大距离 (maxDistance) - 如果声源进一步远离此点,音量将不再降低。这很有用,因为您可能会发现您想模拟距离,但音量可能会消失,而这实际上不是您想要的。默认情况下,它是 10,000(一个无单位的相对值)。我们可以将其保留为这样

js
const maxDistance = 10000;

还有一个参考距离 (refDistance),距离模型会使用它。我们也可以将其保留为默认值 1

js
const refDistance = 1;

然后是衰减因子 (rolloffFactor) - 当声场处理器远离监听器时,音量降低的速度有多快。默认值为 1;让我们将其放大一点以夸大我们的动作。

js
const rollOff = 10;

现在我们可以开始设置录音机的位姿了。这与我们对监听器所做的事情非常相似。这些也是我们在使用界面上的控件时要更改的参数。

js
const positionX = posX;
const positionY = posY;
const positionZ = posZ;

const orientationX = 0.0;
const orientationY = 0.0;
const orientationZ = -1.0;

注意我们 z 方向上的负值 - 这将录音机设置为面对我们。正值会将声源设置为背对我们。

让我们使用相关的构造函数来创建我们的声场节点,并将我们上面设置的所有参数传递进去

js
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 变换 以实际执行移动时,我们将更改这些值。最后,我们将设置一些边界,以便我们的录音机不会在任何方向上移动得太远

js
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 变换以及更新声场节点属性的位姿值以适当地更改声音。

首先,让我们看看我们的左右、上下值,因为这些值非常简单。我们将沿着这些轴移动录音机并更新相应的位置。

js
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;
  }
}

我们的前后值也是类似的故事

js
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.sinMath.cos 来帮助我们绘制该圆。

让我们设置一个旋转速率,稍后我们将将其转换为弧度范围值,用于Math.sinMath.cos,当我们想要在旋转收音机时计算新的坐标时。

js
// 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 轴)。

js
// get degrees for CSS
const degreesX = (q * 180) / Math.PI;
const degreesY = (q * 180) / Math.PI;

让我们以左侧旋转为例。我们需要更改平移器坐标的 x 方向和 z 方向,以便围绕 y 轴进行左侧旋转。

js
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;

确实有点令人困惑,但我们正在做的是使用正弦和余弦来帮助我们计算坐标旋转收音机所需的圆周运动。

我们可以对所有轴执行此操作。我们只需要选择要更新的正确方向以及是否需要正增量或负增量。

js
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函数。

js
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;
}

连接我们的控件

连接我们的控制按钮相对简单——现在我们可以监听控件上的鼠标事件并运行此函数,并在鼠标释放时停止它。

js
// 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 包含我们希望受平移器节点影响的音频元素。

html
<audio src="myCoolTrack.mp3"></audio>

我们需要从该元素中获取源并使用AudioContext.createMediaElementSource将其传入 Web Audio API。

js
// get the audio element
const audioElement = document.querySelector("audio");

// pass it into the audio context
const track = audioContext.createMediaElementSource(audioElement);

接下来,我们必须连接我们的音频图。我们将输入(音轨)连接到我们的修改节点(平移器)到我们的目标(在本例中为扬声器)。

js
track.connect(panner).connect(audioCtx.destination);

让我们创建一个播放按钮,当点击时,它将根据当前状态播放或暂停音频。

html
<button data-playing="false" role="switch">Play/Pause</button>
js
// 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。

总结

希望本文能让您深入了解 Web Audio 空间化是如何工作的,以及每个PannerNode属性的作用(它们有很多)。这些值有时很难操作,并且根据您的用例,可能需要一段时间才能正确设置它们。

注意:不同浏览器中音频空间化的声音略有不同。平移器节点在后台进行了一些非常复杂的数学运算;这里有一些测试,因此您可以跟踪此节点在不同平台上的内部工作状态。

同样,您可以在这里查看最终演示,以及最终源代码在这里。还有一个Codepen 演示

如果您正在使用 3D 游戏和/或 WebXR,最好利用 3D 库来创建此类功能,而不是尝试从头开始自己完成所有操作。我们在本文中自己编写了代码,以便让您了解它是如何工作的,但是通过利用其他人之前完成的工作,您可以节省大量时间。