מקרה לדוגמה – The Sounds of Racer

מבוא

Racer הוא ניסוי ב-Chrome למספר משתתפים במספר מכשירים. משחק מכוניות רטרו שבו השחקנים מתחרים במסכים שונים. בטלפונים או בטאבלטים עם Android או iOS. כל אחד יכול להצטרף. אין אפליקציות. אין הורדות. רק באינטרנט לנייד.

חברת Plan8 יצרה יחד עם החברים שלנו ב-14islands את חוויית המוזיקה והצליל הדינמיים על סמך קומפוזיציה מקורית של ג'ורג'יו מורדר (Giorgio Moroder). במשחק Racer יש צלילים תגובתיים של מנועים, אפקטים קוליים של מרוצים, אבל חשוב מכך, מיקס מוזיקה דינמי שמופץ בכמה מכשירים כשהמתחרים מצטרפים. זוהי התקנה של כמה רמקולים שמכילה סמארטפונים.

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

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

יצירת הצלילים

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

צליל מנוע

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

כדי לקבל השראה, חיברנו כמה מהסינתיסייזרים המודולריים של חברנו Jon Ekstrand והתחלנו להתעסק בהם. אהבנו את מה ששמענו. כך זה נשמע עם שני מתנדים, מסננים נעימים ו-LFO.

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

סינתיסייזר מודולרי להשראה ליצירת צלילים של מנועים

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

הפתרון היעיל ביותר היה:

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

כך זה נראה

גרפיקה של צליל מנוע

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

function throttleOn(throttle) {
    //Calculate the start position depending 
    //on the current amount of throttle.
    //By multiplying throttle we get a start position 
    //between 0 and 3 seconds.
    var startPosition = throttle * 3;

    var audio = context.createBufferSource();
    audio.buffer = loadedBuffers["accelerate_and_loop"];

    //Sets the loop positions for the buffer source.
    audio.loopStart = 5;
    audio.loopEnd = 9;

    //Starts the buffer source at the current time
    //with the calculated offset.
    audio.start(context.currentTime, startPosition);
}

נסה את זה

מפעילים את המנוע ולוחצים על הלחצן 'מצמד'.

<input type="button" id="playstop" value = "Start/Stop Engine" onclick='playStop()'>
<input type="button" id="throttle" value = "Throttle" onmousedown='throttleOn()' onmouseup='throttleOff()'>

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

איך מקבלים את הסנכרון

יחד עם דייוויד לינדקוויסט (David Lindkvist) מ-14islands, התחלנו לבדוק לעומק איך לגרום למכשירים לפעול בסנכרון מושלם. התיאוריה הבסיסית פשוטה. המכשיר שואל את השרת מה השעה, מחשב את זמן האחזור ברשת ומחשב את הפרש השעון המקומי.

syncOffset = localTime - serverTime - networkLatency

בעזרת הפרש הזמן הזה, כל המכשירים המחוברים חולקים את אותו מושג של זמן. פשוט, נכון? (שוב, בתיאוריה).

חישוב זמן האחזור של הרשת

אפשר להניח שזמן האחזור הוא מחצית הזמן שלוקח לשלוח בקשה ולקבל תשובה מהשרת:

networkLatency = (receivedTime - sentTime) × 0.5

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

למרבה המזל, המוח שלנו לא מבחין אם יש עיכוב קל בצליל. מחקרים הראו שנדרש עיכוב של 20 עד 30 אלפיות השנייה (ms) כדי שהמוח שלנו יזהה צלילים כנפרדים. עם זאת, אחרי כ-12 עד 15 אלפיות השנייה, תתחילו "להרגיש" את ההשפעות של אות עם עיכוב, גם אם לא תוכלו "לזהות" אותו באופן מלא. בדקנו כמה פרוטוקולים מקובלים לסנכרון זמן, חלופות פשוטות יותר וניסינו להטמיע חלק מהם בפועל. בסופו של דבר, בזכות התשתית של Google עם זמן אחזור קצר, הצלחנו לדגום פשוט צרור של בקשות ולהשתמש בדגימה עם זמן האחזור הנמוך ביותר כמקור להפניה.

מניעת סטייה של שעון

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

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

תזמון שירים והחלפת סדרים

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

  • Client(1) מפעיל את השיר.
  • Client(n) שואל את הלקוח הראשון מתי השיר התחיל.
  • Client(n) מחשב נקודת ייחוס לזמן שבו השיר התחיל באמצעות ההקשר של האודיו באינטרנט, תוך התחשבות ב-syncOffset ובזמן שחלף מאז יצירת ההקשר של האודיו.
  • playDelta = Date.now() - syncOffset - songStartTime - context.currentTime
  • Client(n) מחשב כמה זמן השיר פועל באמצעות playDelta. הכלי לתזמון השירים משתמש בנתון הזה כדי לדעת איזה פס בסדר הנוכחי צריך להישמע בשלב הבא.
  • playTime = playDelta + context.currentTime nextBar = Math.ceil((playTime % loopDuration) ÷ barDuration) % numberOfBars

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

מבט קדימה

תמיד חשוב לתזמן מראש כשמשתמשים ב-setTimeout או ב-setInterval ב-JavaScript. הסיבה לכך היא שהשעון של JavaScript לא מדויק במיוחד, וקל לשבש קריאות חוזרות מתוזמנות בעשרות אלפיות השנייה או יותר בגלל פריסה, רינדור, איסוף אשפה ו-XMLHTTPRequests. במקרה שלנו, נאלצנו להביא בחשבון גם את הזמן שנדרש לכל הלקוחות לקבל את אותו אירוע ברשת.

ספיריטס של אודיו

שילוב של צלילים בקובץ אחד הוא דרך מצוינת לצמצם את מספר בקשות ה-HTTP, גם ל-HTML Audio וגם ל-Web Audio API. זו גם הדרך הטובה ביותר להפעיל צלילים באופן תגובתי באמצעות אובייקט האודיו, כי אין צורך לטעון אובייקט אודיו חדש לפני ההפעלה. כבר יש כמה הטמעות טובות ששימשו אותנו כנקודת התחלה. הרחבנו את ה-sprite שלנו כדי שיפעל באופן מהימן גם ב-iOS וגם ב-Android, וגם כדי לטפל במקרים חריגים שבהם המכשירים נכנסים למצב שינה.

ב-Android, רכיבי האודיו ממשיכים לפעול גם אם מעבירים את המכשיר למצב שינה. במצב שינה, ביצוע JavaScript מוגבל כדי לחסוך בסוללה, ואין אפשרות להסתמך על requestAnimationFrame,‏ setInterval או setTimeout כדי להפעיל פונקציות חזרה (callbacks). זו בעיה כי ספריית האודיו מסתמכת על JavaScript כדי להמשיך לבדוק אם צריך להפסיק את ההפעלה. גרוע מכך, במקרים מסוימים, הערך של currentTime של רכיב האודיו לא מתעדכן, למרות שהאודיו עדיין פועל.

כדאי לעיין בהטמעת AudioSprite שבה השתמשנו ב-Chrome Racer כחלופה ל-Web Audio.

רכיב אודיו

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

  • עיכובים או בעיות שקשורות לתזמון. רכיב האודיו לא תמיד מופעל בדיוק כשמבקשים ממנו להפעיל. מכיוון ש-JavaScript הוא תהליך יחיד, יכול להיות שהדפדפן עסוק, וכתוצאה מכך יהיו עיכובים בהפעלה של עד שתיים שניות.
  • עיכובים בהפעלה מונעים לפעמים הפעלה חלקה של הלולאה. במחשבים אפשר להשתמש באחסון כפול כדי ליצור לולאות ללא הפסקות, אבל במכשירים ניידים אין אפשרות כזו כי:
    • ברוב המכשירים הניידים לא ניתן להפעיל יותר מרכיב אודיו אחד בכל פעם.
    • נפח קבוע. לא ניתן לשנות את עוצמת הקול של אובייקט אודיו ב-Android או ב-iOS.
  • ללא טעינה מראש. במכשירים ניידים, אלמנט האודיו לא יתחיל לטעון את המקור שלו אלא אם ההפעלה תתחיל במטפל touchStart.
  • מחפשים בעיות. אחזור הערך של duration או הגדרת currentTime ייכשלו אם השרת לא תומך ב-HTTP Byte-Range. חשוב לשים לב לזה אם אתם יוצרים ספרייט אודיו כמו שעשינו.
  • האימות הבסיסי ב-MP3 נכשל. במכשירים מסוימים לא ניתן לטעון קובצי MP3 שמוגנים באמצעות אימות בסיסי, לא משנה באיזה דפדפן אתם משתמשים.

מסקנות

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