שילוב אודיו מקומי ו-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);
...

Velocity

עכשיו, אחרי שקיבלנו את המיקומים של המאזין ושל 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) כדי לראות לאן הוא מצביע בסוף. כדי לקבל את הכיוון של מאזין ההקשר, צריך לקבל את וקטור הכיוון של המצלמה. כדי לקבוע את כיוון המאזין צריך גם וקטור למעלה, כי צריך לדעת את זווית הנטייה של הראש של המאזין. כדי לחשב את כיוון המאזין, מקבלים את חלק הסיבוב של מטריצת התצוגה של המצלמה ומכפילים ב-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.

קובצי עזר