Web 音訊空間化基礎知識
除了它豐富多樣的聲音處理(和其他)選項之外,Web Audio API 還提供了設施,讓您能夠模擬聲音在聽眾圍繞聲源移動時的差異,例如在 3D 遊戲中圍繞聲源移動時的聲像定位。這個官方術語稱為 **空間化**,本文將介紹實現此類系統的基礎知識。
空間化基礎
在 Web Audio 中,複雜的 3D 空間化是透過 PannerNode 建立的,用通俗的話說,它本質上是大量的酷炫數學計算,用於使音訊出現在 3D 空間中。想象聲音從您頭頂飛過,從您身後悄悄靠近,或者從您面前掠過。諸如此類。
它對於 WebXR 和遊戲來說非常有用。在 3D 空間中,這是實現逼真音訊的唯一方法。像 three.js 和 A-frame 這樣的庫在處理聲音時會利用它的潛力。值得注意的是,您不必在完整的 3D 空間中移動聲音——您可以只停留在 2D 平面上,因此如果您計劃開發 2D 遊戲,這仍然是您需要的節點。
注意:還有一個 StereoPannerNode,用於處理建立簡單的左右立體聲聲像效果的常見用例。它使用起來簡單得多,但顯然遠不如 PannerNode 靈活。如果您只需要簡單的立體聲聲像效果,我們的 StereoPannerNode 示例(檢視原始碼)應該能滿足您的所有需求。
3D Boombox 演示
為了演示 3D 空間化,我們修改了我們在基礎 使用 Web Audio API 指南中建立的 Boombox 演示版本。檢視 即時 3D 空間化演示(也可以檢視 原始碼)。

Boombox 放置在一個房間內(由瀏覽器視口邊緣定義),在此演示中,我們可以透過提供的控制元件來移動和旋轉它。當我們移動 Boombox 時,它產生的聲音也會相應變化,當它移動到房間的左側或右側時會進行聲像定位,或者當它遠離使用者時或旋轉後揚聲器背對著使用者時會變小等等。這是透過根據該移動設定 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 移入或移出房間。在這裡,我們將監聽者設定在視口中間,並略微位於我們的 Boombox 前面。我們還可以設定監聽者的朝向。這些屬性的預設值效果很好。
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
讓我們建立我們的 PannerNode。它有許多相關的屬性。讓我們一一檢視。
首先,我們可以設定 panningModel。這是用於在 3D 空間中定位音訊的空間化演算法。我們可以將其設定為:
equalpower — 預設值,也是聲像定位的通用計算方式。
HRTF — 這代表“頭相關傳遞函式”,它會考慮人頭對聲音定位的影響。
非常巧妙。讓我們使用 HRTF 模型!
const panningModel = "HRTF";
coneInnerAngle 和 coneOuterAngle 屬性指定聲音從哪裡發出。預設情況下,兩者都是 360 度。我們的 Boombox 揚聲器將具有較小的錐形,我們可以進行定義。內錐是增益(音量)始終模擬最大值的地方,外錐是增益開始衰減的地方。增益會根據 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;
然後是衰減因子(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 設定為朝向我們。正值會將聲源設定為背離我們。
讓我們使用相關的建構函式來建立我們的 PannerNode,並將上面設定的所有引數傳遞進去。
const panner = new PannerNode(audioCtx, {
panningModel,
distanceModel,
positionX,
positionY,
positionZ,
orientationX,
orientationY,
orientationZ,
refDistance,
maxDistance,
rolloffFactor: rollOff,
coneInnerAngle: innerCone,
coneOuterAngle: outerCone,
coneOuterGain: outerGain,
});
移動 Boombox
現在我們要將 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
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 transform 並更新 PannerNode 屬性的位置和方向值,以相應地改變聲音。
首先,讓我們看一下我們的左、右、上、下值,因為它們相當直接。我們將沿著這些軸移動 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;
}
}
我們的向前和向後移動值也類似。
switch (direction) {
// …
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 來幫助我們繪製這個圓。
讓我們設定一個旋轉速率,稍後在計算旋轉 Boombox 時的新座標時,我們將將其轉換為弧度範圍值以用於 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 transforms(請注意,CSS transforms 需要 X 和 Y 軸)。
// Get degrees for CSS
const degreesX = (q * 180) / Math.PI;
const degreesY = (q * 180) / Math.PI;
讓我們以左旋轉為例。我們需要更改 Panner 座標的 X 和 Z 方向,以便圍繞 Y 軸進行左旋轉。
switch (direction) {
// …
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 旋轉所需的座標的圓形運動。
我們可以對所有軸執行此操作。我們只需要選擇正確的方向進行更新,以及是否需要正增量或負增量。
switch (direction) {
// …
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);
});
window.addEventListener("mouseup", () => {
if (moving && moving.frameId) {
cancelAnimationFrame(moving.frameId);
}
});
});
連線我們的圖
我們的 HTML 包含我們要受 PannerNode 影響的音訊元素。
<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);
接下來,我們必須連線我們的音訊圖。我們將輸入(音軌)連線到我們的處理節點(Panner),再連線到我們的目的地(此處為揚聲器)。
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";
}
});
有關播放/控制音訊和音訊圖的更深入資訊,請參閱 使用 Web Audio API。
總結
希望本文能讓您深入瞭解 Web Audio 空間化是如何工作的,以及 PannerNode 的每個屬性的作用(它們很多)。值有時很難操作,根據您的用例,可能需要一些時間才能正確設定它們。
注意:不同瀏覽器在音訊空間化效果方面存在細微差別。PannerNode 在後臺執行了非常複雜的數學計算;這裡有許多測試,因此您可以跟蹤此節點在不同平臺上的內部工作狀態。
如果您正在使用 3D 遊戲和/或 WebXR,最好利用 3D 庫來建立此類功能,而不是從頭開始嘗試自己實現。我們在本文中自行實現是為了讓您瞭解其工作原理,但利用他人已完成的工作將為您節省大量時間。