שילוב אודיו מקומי ו-WebGL

Ilmari Heikkinen

מבוא

במאמר הזה נסביר איך להשתמש בתכונת האודיו לפי מיקום ב-Web Audio API כדי להוסיף אודיו בתלת-ממד לסצנות של WebGL. כדי שהאודיו יהיה אמין יותר, אציג גם את האפקטים הסביבתיים הזמינים ב-Web Audio API. כדי לקבל מבוא מקיף יותר ל-Web Audio API, כדאי לעיין במאמר תחילת העבודה עם Web Audio API של Boris Smus.

כדי ליצור אודיו מיקומי, משתמשים ב-AudioPannerNode ב-Web Audio API. ה-AudioPannerNode מגדיר את המיקום, הכיוון והמהירות של צליל. בנוסף, להקשר האודיו של Web Audio API יש מאפיין של מאזין שמאפשר להגדיר את המיקום, הכיוון והמהירות של המאזין. בעזרת שני הדברים האלה אפשר ליצור צלילים כיווניים עם אפקטים של דופלר ועם פנוינג תלת-ממדי.

בואו נראה איך נראה קוד האודיו של הסצנה שלמעלה. זהו קוד בסיסי מאוד של Audio API. יוצרים כמה צמתים של Audio API ומחברים אותם יחד. צמתים של אודיו הם צלילים בודדים, פקדי עוצמת קול, צמתים של אפקטים וניתנים וכו'. אחרי שיוצרים את התרשים, צריך לחבר אותו ליעד ההקשר של האודיו כדי שיהיה אפשר לשמוע אותו.

// Detect if the audio context is supported.
window.AudioContext = (
  window.AudioContext ||
  window.webkitAudioContext ||
  null
);

if (!AudioContext) {
  throw new Error("AudioContext not supported!");
} 

// Create a new audio context.
var ctx = new AudioContext();

// Create a AudioGainNode to control the main volume.
var mainVolume = ctx.createGain();
// Connect the main volume node to the context destination.
mainVolume.connect(ctx.destination);

// Create an object with a sound source and a volume control.
var sound = {};
sound.source = ctx.createBufferSource();
sound.volume = ctx.createGain();

// Connect the sound source to the volume control.
sound.source.connect(sound.volume);
// Hook up the sound volume control to the main volume.
sound.volume.connect(mainVolume);

// Make the sound source loop.
sound.source.loop = true;

// Load a sound file using an ArrayBuffer XMLHttpRequest.
var request = new XMLHttpRequest();
request.open("GET", soundFileName, true);
request.responseType = "arraybuffer";
request.onload = function(e) {

  // Create a buffer from the response ArrayBuffer.
  ctx.decodeAudioData(this.response, function onSuccess(buffer) {
    sound.buffer = buffer;

    // Make the sound source use the buffer and start playing it.
    sound.source.buffer = sound.buffer;
    sound.source.start(ctx.currentTime);
  }, function onFailure() {
    alert("Decoding the audio buffer failed");
  });
};
request.send();

מיקום

אודיו מיקומי משתמש במיקום של מקורות האודיו ובמיקום של המאזין כדי לקבוע איך לשלב את הקול ברמקולים. מקור אודיו בצד ימין של המאזין יהיה חזק יותר ברמקול השמאלי, ולהפך בצד ימין.

כדי להתחיל, יוצרים מקור אודיו ומצרפים אותו ל-AudioPannerNode. לאחר מכן מגדירים את המיקום של AudioPannerNode. עכשיו יש לכם צליל תלת-ממדי שניתן להזיז. המיקום של מאזין ההקשר של האודיו הוא (0,0,0) כברירת מחדל, כך שכאשר משתמשים בו באופן הזה, המיקום של AudioPannerNode הוא ביחס למיקום המצלמה. בכל פעם שמזיזים את המצלמה, צריך לעדכן את המיקום של AudioPannerNode. כדי שהמיקום של AudioPannerNode יהיה יחסי לעולם, צריך לשנות את המיקום של מאזין ההקשר של האודיו למיקום המצלמה.

כדי להגדיר את המעקב אחר המיקום, עלינו ליצור AudioPannerNode ולחבר אותו לעוצמת הקול הראשית.

...
sound.panner = ctx.createPanner();
// Instead of hooking up the volume to the main volume, hook it up to the panner.
sound.volume.connect(sound.panner);
// And hook up the panner to the main volume.
sound.panner.connect(mainVolume);
...

בכל פריים, מעדכנים את המיקומים של AudioPannerNodes. בדוגמאות שבהמשך אשתמש ב-Three.js.

...
// In the frame handler function, get the object's position.
object.position.set(newX, newY, newZ);
object.updateMatrixWorld();
var p = new THREE.Vector3();
p.setFromMatrixPosition(object.matrixWorld);

// And copy the position over to the sound of the object.
sound.panner.setPosition(p.x, p.y, p.z);
...

כדי לעקוב אחרי מיקום המאזין, מגדירים את מיקום המאזין בהקשר האודיו כך שיתאים למיקום המצלמה.

...
// Get the camera position.
camera.position.set(newX, newY, newZ);
camera.updateMatrixWorld();
var p = new THREE.Vector3();
p.setFromMatrixPosition(camera.matrixWorld);

// And copy the position over to the listener.
ctx.listener.setPosition(p.x, p.y, p.z);
...

מהירות

עכשיו, אחרי שקיבלנו את המיקומים של המאזין ושל AudioPannerNode, נתמקד במהירויות שלהם. שינוי מאפייני המהירות של המאזין ושל AudioPannerNode מאפשר להוסיף אפקט דופלר לקול. יש כמה דוגמאות נחמדות לאפקט דופלר בדף הדוגמאות של Web Audio API.

הדרך הקלה ביותר לקבל את המהירויות של המאזין וה-AudioPannerNode היא לעקוב אחר המיקומים שלהם לכל פריים. המהירות של המאזין היא המיקום הנוכחי של המצלמה בניכוי המיקום של המצלמה בפריים הקודם. באופן דומה, המהירות של AudioPannerNode היא המיקום הנוכחי שלו בניכוי המיקום הקודם שלו.

ניתן לעקוב אחר המהירות על ידי קבלת המיקום הקודם של העצם, לחסר אותו מהמיקום הנוכחי ולחלק את התוצאה בזמן שחלף מאז הפריים האחרון. כך עושים זאת ב-Three.js:

...
var dt = secondsSinceLastFrame;

var p = new THREE.Vector3();
p.setFromMatrixPosition(object.matrixWorld);
var px = p.x, py = p.y, pz = p.z;

object.position.set(newX, newY, newZ);
object.updateMatrixWorld();

var q = new THREE.Vector3();
q.setFromMatrixPosition(object.matrixWorld);
var dx = q.x-px, dy = q.y-py, dz = q.z-pz;

sound.panner.setPosition(q.x, q.y, q.z);
sound.panner.setVelocity(dx/dt, dy/dt, dz/dt);
...

כיוון

הכיוון הוא הכיוון שאליו מצביע מקור הצליל והכיוון שאליו פונה המאזין. בעזרת התכונה 'כיוון' אפשר לדמות מקורות קול כיווניים. לדוגמה, אפשר לחשוב על רמקול כיווני. אם עומדים מול הרמקול, הצליל יהיה חזק יותר מאשר אם עומדים מאחורי הרמקול. חשוב יותר, אתם צריכים את כיוון המאזין כדי לקבוע מאיזה צד של המאזין הצלילים מגיעים. צליל שמגיע מהצד השמאלי צריך לעבור לצד הימני כשאתם מסתובבים.

כדי לקבל את וקטור הכיוון של AudioPannerNode, עליך לקחת את החלק הסיבוב של מטריצת המודל של האובייקט התלת-ממדי פולט צלילים ולהכפיל איתו את הסימן vec3(0,0,1) כדי לראות איפה הוא מצביע. כדי לקבל את הכיוון של מאזין ההקשר, צריך לקבל את וקטור הכיוון של המצלמה. כדי לקבוע את כיוון המאזין צריך גם וקטור למעלה, כי צריך לדעת את זווית הנטייה של הראש של המאזין. כדי לחשב את כיוון ה-listener, יש לקבל את החלק הסיבוב של מטריצת התצוגה של המצלמה ולהכפיל vec3(0,0,1) בשביל הכיוון ו-vec3(0,-1,0) בשביל הווקטור הלמעלה.

כדי שהכיוון ישפיע על הצלילים, צריך גם להגדיר את חרוט האודיו. חרוט הצליל לוקח זווית פנימית, זווית חיצונית ורווח חיצוני. הצליל מושמע בעוצמת הקול הרגילה בתוך הזווית הפנימית, ומשתנה בהדרגה לפי עוצמת הקול החיצונית ככל שמתקרבים לזווית החיצונית. מחוץ לזווית החיצונית, הצליל מושמע בעוצמה חיצונית.

מעקב אחר הכיוון ב-Three.js הוא קצת יותר מסובך, כי הוא כולל קצת מתמטיקה של וקטורים והגדרה של חלק התרגום של מטריצות העולם בגודל 4x4 לאפס. עדיין, לא הרבה שורות קוד.

...
var vec = new THREE.Vector3(0,0,1);
var m = object.matrixWorld;

// Save the translation column and zero it.
var mx = m.elements[12], my = m.elements[13], mz = m.elements[14];
m.elements[12] = m.elements[13] = m.elements[14] = 0;

// Multiply the 0,0,1 vector by the world matrix and normalize the result.
vec.applyProjection(m);
vec.normalize();

sound.panner.setOrientation(vec.x, vec.y, vec.z);

// Restore the translation column.
m.elements[12] = mx;
m.elements[13] = my;
m.elements[14] = mz;
...

כדי לעקוב אחרי כיוון המצלמה, נדרש גם וקטור צפון, ולכן צריך להכפיל וקטור צפון במטריצה של הטרנספורמציה.

...
// The camera's world matrix is named "matrix".
var m = camera.matrix;

var mx = m.elements[12], my = m.elements[13], mz = m.elements[14];
m.elements[12] = m.elements[13] = m.elements[14] = 0;

// Multiply the orientation vector by the world matrix of the camera.
var vec = new THREE.Vector3(0,0,1);
vec.applyProjection(m);
vec.normalize();

// Multiply the up vector by the world matrix.
var up = new THREE.Vector3(0,-1,0);
up.applyProjection(m);
up.normalize();

// Set the orientation and the up-vector for the listener.
ctx.listener.setOrientation(vec.x, vec.y, vec.z, up.x, up.y, up.z);

m.elements[12] = mx;
m.elements[13] = my;
m.elements[14] = mz;
...

כדי להגדיר את חרוט הקול של הצליל, מגדירים את המאפיינים המתאימים של צומת ה-panner. זוויות החרוט הן במעלות ונעות מ-0 ל-360.

...
sound.panner.coneInnerAngle = innerAngleInDegrees;
sound.panner.coneOuterAngle = outerAngleInDegrees;
sound.panner.coneOuterGain = outerGainFactor;
...

הכול ביחד

לסיכום, המאזין להקשר האודיו עוקב אחרי המיקום, הכיוון והמהירות של המצלמה, ורכיבי AudioPannerNodes עוקבים אחרי המיקומים, הכיוונים והמהירויות של מקורות האודיו המתאימים. צריך לעדכן את המיקומים, המהירויות והכיוונים של AudioPannerNodes ושל מאזין ההקשר בכל פריים.

השפעות סביבתיות

אחרי שמגדירים אודיו מיקומי, אפשר להגדיר את האפקטים הסביבתיים של האודיו כדי לשפר את חוויית הצפייה בסצנה התלת-ממדית. נניח שהסצנה מתרחשת בתוך קתדרלה גדולה. בהגדרות ברירת המחדל, הצלילים בסצנה נשמעים כאילו אתם עומדים בחוץ. אי ההתאמה בין הרכיבים החזותיים לבין האודיו מפריעה לתחושת ההתמקדות בסרטון וגורמת לכך שהסצנה פחות מרשימה.

ב-Web Audio API יש ConvolverNode שמאפשר להגדיר את האפקט הסביבתי של צליל. מוסיפים אותו לתרשים העיבוד של מקור האודיו, וכך אפשר להתאים את הצליל להגדרה. אפשר למצוא באינטרנט דוגמאות של תגובות דחף, ולהשתמש בהן ב-ConvolverNodes, ואפשר גם ליצור תשובות משלך. יכול להיות שהתהליך יהיה קצת מסורבל כי צריך להקליט את תגובת הלם של המקום שרוצים לדמות, אבל האפשרות הזו קיימת אם אתם צריכים אותה.

כדי להשתמש ב-ConvolverNodes ליצירת אודיו סביבתי, צריך לחבר מחדש את תרשים עיבוד האודיו. במקום להעביר את הצליל ישירות לעוצמת הקול הראשית, צריך לנתב אותו דרך ConvolverNode. תרצו לשלוט בעוצמת האפקט הסביבתי? תצטרכו גם לנתב את האודיו סביב ה-ConvolverNode. כדי לשלוט בעוצמות המיקס, צריך לצרף ל-ConvolverNode ולאודיו הרגיל רכיבי GainNode.

בתרשים הסופי של עיבוד האודיו שבו אני משתמש, האודיו מהאובייקטים עובר דרך GainNode שמשמש כמיקסר העברה. מהמיקסר מעבירים את האודיו אל ConvolverNode ואל GainNode נוסף, שמשמשים לשלוט בעוצמת האודיו הרגיל. ה-ConvolverNode מחובר ל-GainNode משלו כדי לשלוט בעוצמת האודיו המתעתע. הפלט של ה-GainNodes מחובר למתג עוצמת הקול הראשי.

...
var ctx = new webkitAudioContext();
var mainVolume = ctx.createGain();

// Create a convolver to apply environmental effects to the audio.
var convolver = ctx.createConvolver();

// Create a mixer that receives sound from the panners.
var mixer = ctx.createGain();

sounds.forEach(function(sound){
  sound.panner.connect(mixer);
});

// Create volume controllers for the plain audio and the convolver.
var plainGain = ctx.createGain();
var convolverGain = ctx.createGain();

// Send audio from the mixer to plainGain and the convolver node.
mixer.connect(plainGain);
mixer.connect(convolver);

// Hook up the convolver to its volume control.
convolver.connect(convolverGain);

// Send audio from the volume controls to the main volume control.
plainGain.connect(mainVolume);
convolverGain.connect(mainVolume);

// Finally, connect the main volume to the audio context's destination.
volume.connect(ctx.destination);
...

כדי שה-ConvolverNode יפעל, צריך לטעון דגימה של תגובת דחף למאגר ולגרום ל-ConvolverNode להשתמש בה. טעינת הדוגמה מתבצעת באותו אופן כמו בדגימות צליל רגילות. דוגמה אחת לאופן שבו אפשר לעשות זאת:

...
loadBuffer(ctx, "impulseResponseExample.wav", function(buffer){
  convolver.buffer = buffer;
  convolverGain.gain.value = 0.7;
  plainGain.gain.value = 0.3;
})
...
function loadBuffer(ctx, filename, callback) {
  var request = new XMLHttpRequest();
  request.open("GET", soundFileName, true);
  request.responseType = "arraybuffer";
  request.onload = function() {
    // Create a buffer and keep the channels unchanged.
    ctx.decodeAudioData(request.response, callback, function() {
      alert("Decoding the audio buffer failed");
    });
  };
  request.send();
}

סיכום

במאמר הזה למדתם איך להוסיף אודיו מיקומי לסצנות 3D באמצעות Web Audio API. באמצעות Web Audio API אפשר להגדיר את המיקום, הכיוון והמהירות של מקורות האודיו והמאזין. אם תגדירו אותם לעקוב אחרי האובייקטים בסצנה התלת-ממדית, תוכלו ליצור סאונדסקיפ עשיר לאפליקציות התלת-ממדיות שלכם.

כדי לשפר את חוויית האודיו, אפשר להשתמש ב-ConvolverNode ב-Web Audio API כדי להגדיר את הצליל הכללי של הסביבה. אפשר לדמות מגוון אפקטים וסביבות, החל מקתדרלות ועד לחדרים סגורים, באמצעות Web Audio API.

קובצי עזר