מקרה לדוגמה - סיפור על משחק ב-HTML5 עם אודיו באינטרנט

קוקייה רצנית

צילום מסך של Fieldrunners
צילום מסך של Fieldrunners

Fieldrunners הוא משחק זוכה פרסים בסגנון הגנת המגדלים, שהושק במקור ל-iPhone בשנת 2008. מאז הוא הועבר לפלטפורמות רבות אחרות. אחת מהפלטפורמות החדשות ביותר הייתה דפדפן Chrome באוקטובר 2011. אחד האתגרים בניוד של Fieldrunners לפלטפורמת HTML5 היה כיצד להשמיע צליל.

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

הופיעו מספר אתגרים

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

הטבע של AudioBufferSourceNodes

AudioBufferSourceNodes הן השיטה העיקרית שבה אתם משתמשים כדי להשמיע צלילים ב-WebAudio. חשוב מאוד להבין שהם אובייקט לשימוש חד-פעמי. אתם יוצרים AudioBufferSourceNode, מקצים לו מאגר נתונים זמני, מחברים אותו לתרשים ומפעילים אותו עם noteOn או noteGrainOn. לאחר מכן, אפשר להפעיל את noteTurn כדי להפסיק את ההפעלה, אבל לא להפעיל שוב את המקור באמצעות noteOn או noteGrainOn. צריך ליצור רכיב AudioBufferSourceNode אחר. עם זאת, אפשר להשתמש שוב באותו אובייקט AudioBuffer בסיסי (אבל אפשר להשתמש שוב באותו אובייקט AudioBuffer). למעשה, ניתן אפילו להשתמש במספר פעיל של AudioBufferSourceNodes שמפנה לאותה מכונת AudioBuffer!. אפשר למצוא קטע הפעלה מ-Fieldrunners ב-Let Me a Beat.

תוכן שלא נשמר במטמון

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

השתקה כשמכשיר לא בפוקוס

בעבר היה קשה לזהות כשכרטיסיית המשחק לא בפוקוס. ה-Fieldrunners התחילו להעביר את הנתונים לפני גרסה 13 של Chrome, כך שה-API של חשיפת הדפים החליף את הצורך בקוד המסתובב שלנו כדי לזהות טשטוש כרטיסיות. בכל משחק צריך להשתמש ב-Visibility API כדי לכתוב קטע קצר (קטע קוד קצר) שישתיק או ישהה את האודיו שלו, אם לא ישהה את המשחק כולו. מאחר ש-Fieldrunners השתמשה ב-requestAnimationFrame, השהיית המשחק טופלה באופן מפורש, אבל לא השהיית הצליל.

השהיית צלילים

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

ארכיטקטורת צומת פשוט של אודיו באינטרנט

ל-Fieldrunners יש מודל אודיו פשוט מאוד. המודל הזה יכול לתמוך בקבוצת התכונות הבאה:

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

כדי להשיג את התכונות שצוינו למעלה עם Web Audio, נעשה שימוש ב-3 מהצמתים האפשריים: DestinationNode, GainNode, AudioBufferSourceNode. AudioBufferSourceNodes משמיעים את הצלילים. ה-GainNodes מחברים את AudioBufferSourceNodes. ה-DestinationNode, שנוצר על ידי ההקשר של Web Audio, שנקרא destination (יעד), משמיע צלילים עבור הנגן. ל-Web Audio יש הרבה יותר סוגים של צמתים, אבל רק עם הצמתים האלה אנחנו יכולים ליצור תרשים פשוט מאוד של צלילים במשחק.

תרשים צומת

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

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

function AudioManager() {
  // map for loaded sounds
  this.sounds = {};

  // create our permanent nodes
  this.nodes = {
    destination: this.audioContext.destination,
    masterGain: this.audioContext.createGain(),

    backgroundMusicGain: this.audioContext.createGain(),

    coreEffectsGain: this.audioContext.createGain(),
    effectsGain: this.audioContext.createGain(),
    pausedEffectsGain: this.audioContext.createGain()
  };

  // and setup the graph
  this.nodes.masterGain.connect( this.nodes.destination );

  this.nodes.backgroundMusicGain.connect( this.nodes.masterGain );

  this.nodes.coreEffectsGain.connect( this.nodes.masterGain );
  this.nodes.effectsGain.connect( this.nodes.coreEffectsGain );
  this.nodes.pausedEffectsGain.connect( this.nodes.coreEffectsGain );
}

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

function setArbitraryVolume() {
  var musicGainNode = this.nodes.backgroundMusicGain;

  // set music volume to 50%
  musicGainNode.gain.value = 0.5;
}

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

function arbitraryCrossfade( track1, track2 ) {
  track1.gain.linearRampToValueAtTime( 0, 1 );
  track2.gain.linearRampToValueAtTime( 1, 1 );
}

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

השהיית צלילים

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

AudioManager.prototype.pauseEffects = function() {
  this.nodes.effectsGain.disconnect();
}

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

AudioManager.prototype.resumeEffects = function() {
  this.nodes.effectsGain.connect( this.nodes.coreEffectsGain );
}

אחרי ששלחתי אל Fieldrunners, גילינו שניתוק של צומת או תת-תרשים לא יגרום להשהיה של ההפעלה של AudioBufferSourceNodes. למעשה ניצלנו באג ב-WebAudio, שמפסיק בשלב זה את ההפעלה של צמתים שאינם מחוברים לצומת היעד בתרשים. אז כדי להיות מוכנים לתיקון העתידי, אנחנו זקוקים לקוד כמו בדוגמה הבאה:

AudioManager.prototype.pauseEffects = function() {
  this.nodes.effectsGain.disconnect();

  var now = Date.now();
  for ( var name in this.sounds ) {
    var sound = this.sounds[ name ];

    if ( !sound.ignorePause && ( now - sound.source.noteOnAt < sound.buffer.duration * 1000 ) ) {
      sound.pausedAt = now - sound.source.noteOnAt;
      sound.source.noteOff();
    }
  }
}

AudioManager.prototype.resumeEffects = function() {
  this.nodes.effectsGain.connect( this.nodes.coreEffectsGain );

  var now = Date.now();
  for ( var name in this.sounds ) {
    if ( sound.pausedAt ) {
      this.play( sound.name );
      delete sound.pausedAt;
    }
  }
};

אם היינו יודעים זאת מוקדם יותר, שהיינו פוגעים בבאג, המבנה של קוד האודיו שלנו יהיה שונה מאוד. לכן, השינויים השפיעו על כמה קטעים במאמר הזה. יש לזה השפעה ישירה כאן, אבל גם בקטעי הקוד שלנו ב-Losing Focus וב-"Give Me a Beat". כדי לדעת איך זה עובד בפועל, צריך לבצע שינויים בתרשים הצמתים של Fieldrunners (מאחר שיצרנו צמתים לקיצור ההפעלה) וגם בקוד הנוסף שיתעד ויספק את מצבי ההשהיה ש-Web Audio לא מבצע בעצמו.

מאבדים את המיקוד

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

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

function AudioManager() {
  // map and node setup
  // ...

  // disable all sound when on other tabs
  var self = this;
  window.addEventListener( 'webkitvisibilitychange', function( e ) {
    if ( document.webkitHidden ) {
      self.nodes.masterGain.disconnect();

      // As noted in Pausing Sounds disconnecting isn't enough.
      // For Fieldrunners calling our new pauseEffects method would be
      // enough to accomplish that, though we may still need some logic
      // to not resume if already paused.
      self.pauseEffects();
    } else {
      self.nodes.masterGain.connect( this.nodes.destination );
      self.resumeEffects();
    }
  });
}

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

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

תן לי ביט

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

במקום להשמיע כמה עותקים של הצליל למשך מספר מופעים של ישות במשחק, למשל דמות מתה, Fieldrunners משמיע צליל אחד בלבד פעם אחת למשך כל הסרטון. אם נדרש צליל לאחר סיום ההפעלה, ניתן להפעיל אותו מחדש אבל לא בזמן ההפעלה. זו החלטה לגבי עיצוב האודיו של Fieldrunners, כי יש בה צלילים שמבקשים להפעיל במהירות, שאחרת היו מתקשים אם יש אפשרות להפעיל מחדש או ליצור קקופוניה לא נעימה אם תינתן לה הרשאה להשמיע כמה מופעים. יש להשתמש ב-AudioBufferSourceNodes כתמונה אחת. יצירת צומת, צירוף מאגר נתונים זמני, הגדרת ערך בוליאני בלולאה במקרה הצורך, חיבור לצומת בתרשים שיוביל ליעד, call noteOn או noteGrainOn, ואפשר גם להפעיל את NoteDisabled.

ב-Fieldrunners נראה כך:

AudioManager.prototype.play = function( options ) {
  var now = Date.now(),
    // pull from a map of loaded audio buffers
    sound = this.sounds[ options.name ],
    channel,
    source,
    resumeSource;

  if ( !sound ) {
    return;
  }

  if ( sound.source ) {
    var source = sound.source;
    if ( !options.loop && now - source.noteOnAt > sound.buffer.duration * 1000 ) {
      // discard the previous source node
      source.stop( 0 );
      source.disconnect();
    } else {
      return;
    }
  }

  source = this.audioContext.createBufferSource();
  sound.source = source;
  // track when the source is started to know if it should still be playing
  source.noteOnAt = now;

  // help with pausing
  sound.ignorePause = !!options.ignorePause;

  if ( options.ignorePause ) {
    channel = this.nodes.pausedEffectsGain;
  } else {
    channel = this.nodes.effectsGain;
  }

  source.buffer = sound.buffer;
  source.connect( channel );
  source.loop = options.loop || false;

  // Fieldrunners' current code doesn't consider sound.pausedAt.
  // This is an added section to assist the new pausing code.
  if ( sound.pausedAt ) {
    source.start( ( sound.buffer.duration * 1000 - sound.pausedAt ) / 1000 );
    source.noteOnAt = now + sound.buffer.duration * 1000 - sound.pausedAt;

    // if you needed to precisely stop sounds, you'd want to store this
    resumeSource = this.audioContext.createBufferSource();
    resumeSource.buffer = sound.buffer;
    resumeSource.connect( channel );
    resumeSource.start(
      0,
      sound.pausedAt,
      sound.buffer.duration - sound.pausedAt / 1000
    );
  } else {
    // start play immediately with a value of 0 or less
    source.start( 0 );
  }
}

יותר מדי סטרימינג

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

מאחר שכל האפקטים הקוליים הופעלו באמצעות Web Audio, העברנו גם את מוזיקת הרקע ל-Web Audio. כלומר, טוענים את המסלולים באותו אופן שבו טענו את כל האפקטים באמצעות XMLHttpRequests וסוג התגובה שלarraybuffer.

AudioManager.prototype.load = function( options ) {
  var xhr,
      // pull from a map of name, object pairs
      sound = this.sounds[ options.name ];

  if ( sound ) {
    // this is a great spot to add success methods to a list or use promises
    // for handling the load event or call success if already loaded
    if ( sound.buffer && options.success ) {
      options.success( options.name );
    } else if ( options.success ) {
      sound.success.push( options.success );
    }

    // one buffer is enough so shortcut here
    return;
  }

  sound = {
    name: options.name,
    buffer: null,
    source: null,
    success: ( options.success ? [ options.success ] : [] )
  };
  this.sounds[ options.name ] = sound;

  xhr = new XMLHttpRequest();
  xhr.open( 'GET', options.path, true );
  xhr.responseType = 'arraybuffer';
  xhr.onload = function( e ) {
    sound.buffer = self._context.createBuffer( xhr.response, false );

    // call all waiting handlers
    sound.success.forEach( function( success ) {
      success( sound.name );
    });
    delete sound.success;
  };
  xhr.onerror = function( e ) {

    // failures are uncommon but you want to do deal with them

  };
  xhr.send();
}

סיכום

Fieldrunners היה פתרון מצוין ל-Chrome ול-HTML5. מחוץ להרצאה של החברה, שמביאה אלפי קווי C++ ל-JavaScript, כמה דילמות והחלטות מעניינות שספציפיות ל-HTML5. על מנת לחזור על אחד מהאובייקטים האחרים, AudioBufferSourceNodes הם אובייקטים לשימוש חד-פעמי. יוצרים אותם, מצרפים אודיו מאגר נתונים זמני, מחברים אותו לתרשים Web Audio, ומפעילים עם NoteOn או noteGrainOn. רוצה להשמיע את הצליל הזה שוב? אחר כך יוצרים קובץ AudioBufferSourceNode נוסף.