دراسة حالة - The Sounds of Racer

مقدمة

Racer هي تجربة Chrome متعددة اللاعبين والأجهزة. لعبة سيارات سباق كلاسيكية يتم تشغيلها على شاشات متعددة. على الهواتف أو الأجهزة اللوحية، سواء كانت تعمل بنظام التشغيل Android أو iOS يمكن لأي شخص الانضمام. ليس هناك أي تطبيقات. لم يتم تنزيل أي محتوى. الويب المتوافق مع الأجهزة الجوّالة فقط

تعاون فريق Plan8 مع فريق 14islands لإنشاء تجربة موسيقية وصوتية ديناميكية استنادًا إلى تركيبة أصلية من تأليف Giorgio Moroder. تتضمن لعبة Racer أصوات محركات سريعة الاستجابة وتأثيرات صوتية للسباقات، ولكن الأهم من ذلك هو مزيج الموسيقى الديناميكي الذي يتم توزيعه على عدة أجهزة عندما ينضم المتسابقون. وهو عبارة عن تركيب مكبّرات صوت متعددة تتألف من هواتف ذكية.

لقد كنا نحاول ربط أجهزة متعددة معًا لبعض الوقت. أجرينا تجارب موسيقية لتقسيم الصوت على أجهزة مختلفة أو قفزه بين الأجهزة، لذلك كنّا متحمّسين لتطبيق هذه الأفكار على ميزة "المتسابق".

وبشكل أكثر تحديدًا، أردنا اختبار ما إذا كان بإمكاننا إنشاء مقطع موسيقي على جميع الأجهزة مع انضمام المزيد من اللاعبين إلى اللعبة، بدءًا من الطبول والباس، ثم إضافة الجيتار والأصوات الإلكترونية وما إلى ذلك. لقد أنشأنا بعض العروض الموسيقية التجريبية وبدأنا في الترميز. كان تأثير مكبّرات الصوت المتعددة مفيدًا جدًا. لم تكن المزامنة تعمل بشكل صحيح في هذه المرحلة، ولكن عندما سمعنا طبقات الصوت تنتشر على الأجهزة، عرفنا أنّنا على وشك تحقيق هدفنا.

إنشاء الأصوات

حدّد مختبر Google الإبداعي اتجاهًا إبداعيًا للصوت والموسيقى. أردنا استخدام أجهزة المزج التناظرية لإنشاء المؤثرات الصوتية بدلاً من تسجيل الأصوات الحقيقية أو اللجوء إلى مكتبات الأصوات. عرفنا أيضًا أنّ مكبّر الصوت في معظم الحالات سيكون صغيرًا في الهاتف أو الجهاز اللوحي، لذا كان يجب أن تكون الأصوات محدودة النطاق لتجنب تشويهها. وقد تبيّن أنّ هذا الأمر يشكّل تحديًا كبيرًا. عندما تلقّينا المسودات الموسيقية الأولى من "جورجيو"، شعرنا بالارتياح لأنّ موسيقاه كانت تتوافق تمامًا مع الأصوات التي أنشأناها.

صوت المحرّك

كان التحدي الأكبر في برمجة الأصوات هو العثور على أفضل صوت للمحرك وتحديد سلوكه. كان مضمار السباق يشبه مضمار سباق سيارات الفورمولا 1 أو NASCAR، لذا كان يجب أن تبدو السيارات سريعة وقوية. وفي الوقت نفسه، كانت السيارات صغيرة جدًا، لذا لن يؤدي صوت المحرّك العالي إلى ربط الصوت بالمرئيات. لا يمكننا تشغيل صوت محرك قوي في مكبّر صوت الجهاز الجوّال، لذا كان علينا ابتكار فكرة أخرى.

للحصول على بعض الإلهام، استخدمنا بعضًا من مجموعة صديقنا جون إكستراند من أجهزة المزج التجميعية وبدأنا بالتجربة. لقد أعجبنا ما سمعناه. هذا هو الصوت الذي نتج عن استخدام جهازَيّ تشويش وبعض الفلاتر الرائعة وLFO.

سبق أن تم إعادة تصميم الأجهزة التناظرية بنجاح كبير باستخدام Web Audio API، لذا كانت لدينا آمال كبيرة وبدأنا في إنشاء آلة موسيقية إلكترونية بسيطة في Web Audio. سيكون الصوت الذي يتم إنشاؤه هو الأكثر استجابةً، ولكنه سيستهلك طاقة المعالجة في الجهاز. كان علينا تقليل العناصر إلى أقصى حدّ لتوفير كل الموارد التي يمكننا توفيرها لكي تعمل العناصر المرئية بسلاسة. لذلك، غيّرنا أسلوبنا وبدأنا بتشغيل عيّنات صوتية بدلاً من ذلك.

تركيب صوت اصطناعي للاستفادة من أفكار حول صوت المحرّك

هناك العديد من التقنيات التي يمكن استخدامها لإنشاء صوت محرك من عيّنات. إنّ الطريقة الأكثر شيوعًا لألعاب وحدة التحكّم هي إضافة طبقة من الأصوات المتعددة (كلما زاد عددها كان ذلك أفضل) للمحرك عند عدد دورات في الدقيقة مختلف (مع الحمل)، ثم إضافة انتقال صوتي وتغيير في درجة الصوت بينهما. بعد ذلك، أضِف طبقة من الأصوات المتعددة للمحرك الذي يعمل بسرعة دوران (بدون حمل) بنفس عدد دورات في الدقيقة، واستخدم تقنية "محو الصوت" و"تغيير النغمات" بين الصوتين. إذا تم تنفيذ الانتقال السلس بين هذه الطبقات بشكل صحيح عند تغيير السرعة، سيبدو الصوت واقعيًا جدًا، ولكن فقط إذا كان لديك عدد كبير من الملفات الصوتية. يجب ألا يكون نطاق التحويل واسعًا جدًا، وإلا سيبدو الصوت اصطناعيًا للغاية. وبما أنّنا أردنا تقليل وقت التحميل، لم يكن هذا الخيار مناسبًا لنا. لقد جرّبنا استخدام خمسة أو ستة ملفات صوتية لكل طبقة، ولكنّ الصوت كان مخيّبًا للآمال. كان علينا إيجاد طريقة تتطلب عددًا أقل من الملفات.

تبيّن أنّ الحل الأكثر فعالية هو:

  • ملف صوتي واحد يتضمّن صوت التسارع وتغيير التروس ومزامنة مع التسارع المرئي للسيارة وينتهي بحلقة مبرمَجة بأعلى درجة / دورة في الدقيقة تُعدّ 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()'>

لذلك، بعد الحصول على ثلاثة ملفات صوتية صغيرة فقط ومحرك صوتي جيد، قررنا الانتقال إلى التحدي التالي.

بدء المزامنة

بالتعاون مع "ديفيد ليندكفيست" من شركة 14islands، بدأنا في النظر بشكل أعمق في تشغيل الأجهزة بشكل متزامن تمامًا. إنّ النظرية الأساسية بسيطة. يطلب الجهاز من الخادم معرفة الوقت، ويأخذ في الاعتبار وقت استجابة الشبكة، ثم يحسب انحراف الساعة المحلية.

syncOffset = localTime - serverTime - networkLatency

وبفضل هذا الاختلاف، يشترك كل جهاز متصل في مفهوم الوقت نفسه. الأمر سهل، أليس كذلك؟ (مرة أخرى، من الناحية النظرية).

احتساب وقت استجابة الشبكة

يمكننا افتراض أنّ وقت الاستجابة هو نصف الوقت الذي يستغرقه طلب استجابة من الخادم وتلقّيها:

networkLatency = (receivedTime - sentTime) × 0.5

تكمن المشكلة في هذا الافتراض في أنّ مدة الرحلة ذهابًا وإيابًا إلى الخادم ليست متماثلة دائمًا، أي أنّ الطلب قد يستغرق وقتًا أطول من الاستجابة أو العكس. وكلما زاد وقت استجابة الشبكة، زاد تأثير هذا الاختلاف، ما يؤدي إلى تأخير الأصوات وتشغيلها بشكل غير متزامن مع الأجهزة الأخرى.

لحسن الحظ، تم تصميم أدمغتنا بحيث لا تلاحظ تأخُّر الأصوات قليلاً. أظهرت الدراسات أنّه يستغرق الأمر من 20 إلى 30 ملي ثانية قبل أن يدرك دماغنا الأصوات على أنّها منفصلة. ومع ذلك، بعد مرور 12 إلى 15 ملي ثانية تقريبًا، ستبدأ في "الشعور" بتأثيرات الإشارة المتأخرة حتى إذا لم تتمكّن من "الشعور" بها بالكامل. لقد حققنا في بعض بروتوكولات مزامنة الوقت الراسخة وبدائل أبسط، وحاولنا تطبيق بعضها عمليًا. في النهاية، وبفضل البنية الأساسية ذات الاستجابة المنخفضة في Google، تمكّنا من تحليل عيّنة من الطلبات واستخدام العيّنة ذات الاستجابة الأقل كمرجع.

معالجة تغيُّر الوقت

لقد نجحت. كان لدينا أكثر من 5 أجهزة تُشغّل نبضة بالتزامن التام، ولكن لفترة قصيرة فقط. بعد تشغيل المحتوى لبضع دقائق، كانت الأجهزة تتباعد عن بعضها على الرغم من أنّنا حدّدنا وقت تشغيل الصوت باستخدام وقت سياق Web Audio API الدقيق للغاية. كان التأخير يتراكم ببطء، ببضعة مللي ثانية فقط في المرة الواحدة، وكان من الصعب رصده في البداية، ولكن كان يؤدي إلى عدم مزامنة الطبقات الموسيقية تمامًا بعد تشغيلها لفترات زمنية أطول. مرحبًا، لدينا مشكلة في ضبط الوقت.

كان الحلّ هو إعادة المزامنة كل بضع ثوانٍ وحساب توقيت جديد للساعة وإدخاله بسلاسة في جدولة الصوت. للحد من خطر حدوث تغييرات ملحوظة في الموسيقى بسبب تأخُّر الشبكة، قرّرنا تخفيف التغيير من خلال الاحتفاظ بسجلّ لآخر حالات عدم التوافق في المزامنة واحتساب متوسط لها.

تحديد مواعيد الأغاني وتبديل الترتيبات

عند إنشاء تجربة صوتية تفاعلية، لن يعود بإمكانك التحكّم في وقت تشغيل أجزاء من الأغنية، لأنّك تعتمد على إجراءات المستخدمين لتغيير الحالة الحالية. كان علينا التأكّد من أنّه يمكننا التبديل بين الترتيبات في الأغنية في الوقت المناسب، ما يعني أنّه كان على أداة الجدول الزمني أن تكون قادرة على احتساب الوقت المتبقّي من المقطع الصوتي الذي يتم تشغيله حاليًا قبل التبديل إلى الترتيب التالي. في النهاية، أصبحت الخوارزمية على النحو التالي:

  • يبدأ Client(1) الأغنية.
  • يسأل "Client(n)" العميل الأول عن وقت بدء الأغنية.
  • تحسب دالة Client(n) نقطة مرجعية لوقت بدء تشغيل الأغنية باستخدام سياق Web Audio، مع الأخذ في الاعتبار مَعلمة 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 ليست دقيقة جدًا، ويمكن بسهولة أن يتم تحريف عمليات الاستدعاء المُجدوَلة بمقدار عشرات المللي ثانية أو أكثر بسبب التنسيق والعرض وجمع المهملات وطلبات XMLHTTPRequest. في حالتنا، كان علينا أيضًا مراعاة الوقت الذي يستغرقه جميع العملاء لتلقّي الحدث نفسه عبر الشبكة.

ملفات الصوت المتعدّدة

يُعدّ دمج الأصوات في ملف واحد طريقة رائعة لتقليل طلبات HTTP، سواءً لميزة HTML Audio أو واجهة برمجة التطبيقات Web Audio API. وهي أيضًا أفضل طريقة لتشغيل الأصوات بشكل استجابة باستخدام عنصر الصوت، لأنّه لا يحتاج إلى تحميل عنصر صوت جديد قبل التشغيل. هناك بعض عمليات التنفيذ الجيدة التي استخدمناها كنقطة بداية. لقد وسّعنا نطاق عمل الصور الرمزية لتعمل بشكل موثوق على كلّ من أجهزة iOS وAndroid، بالإضافة إلى معالجة بعض الحالات الغريبة التي تؤدي إلى إيقاف الأجهزة في وضع السكون.

على أجهزة Android، يستمر تشغيل عناصر الصوت حتى إذا تم ضبط الجهاز على وضع السكون. في وضع السكون، يتم الحد من تنفيذ JavaScript للحفاظ على مستوى طاقة البطارية، ولا يمكنك الاعتماد على requestAnimationFrame أو setInterval أو setTimeout لتشغيل وظائف الاستدعاء. هذه مشكلة لأنّ الصور الرمزية الصوتية تعتمد على JavaScript لمواصلة التحقّق مما إذا كان يجب إيقاف التشغيل. والأسوأ من ذلك، أنّه في بعض الحالات لا يتم تعديل currentTime لعنصر الصوت على الرغم من استمرار تشغيل الصوت.

اطّلِع على استخدام AudioSprite الذي استخدمناه في لعبة Chrome Racer كحل احتياطي غير Web Audio.

عنصر الصوت

عندما بدأنا العمل على Racer، لم يكن متصفّح Chrome لنظام التشغيل Android متوافقًا مع Web Audio API. واجهنا بعض التحديات المثيرة للاهتمام بسبب منطق استخدام HTML Audio لبعض الأجهزة وWeb Audio API للأجهزة الأخرى، بالإضافة إلى إخراج الصوت المتقدّم الذي أردنا تحقيقه. ولحسن الحظ، انتهت هذه المشكلة. تم تنفيذ Web Audio API في الإصدار التجريبي من Android M28.

  • التأخيرات أو مشاكل التوقيت لا يتم تشغيل عنصر الصوت دائمًا في الوقت المحدّد. بما أنّ JavaScript عبارة عن سلسلة محادثات واحدة، قد يكون المتصفّح مشغولاً، ما يؤدي إلى تأخير التشغيل لمدة تصل إلى ثانيتَين.
  • تؤدي تأخيرات التشغيل إلى عدم إمكانية تكرار الفيديو بسلاسة في بعض الأحيان. على أجهزة الكمبيوتر المكتبي، يمكنك استخدام التخزين المؤقت المزدوج لتحقيق حلقات بدون فواصل إلى حدٍ ما، ولكن هذا الخيار غير متاح على الأجهزة الجوّالة، وذلك للأسباب التالية:
    • لا يمكن تشغيل أكثر من عنصر صوت واحد في الوقت نفسه على معظم الأجهزة الجوّالة.
    • مستوى صوت ثابت لا يسمح لك نظاما التشغيل Android وiOS بتغيير مستوى صوت عنصر الصوت.
  • لا يتم التحميل المُسبَق. على الأجهزة الجوّالة، لن يبدأ عنصر الصوت في تحميل مصدره ما لم يتم بدء التشغيل في معالِج touchStart.
  • البحث عن المشاكل لن تنجح عملية الحصول على duration أو ضبط currentTime ما لم يكن خادمك متوافقًا مع نطاق وحدات البايت في HTTP. انتبه إلى هذه المشكلة إذا كنت بصدد إنشاء شريحة صوتية مثلما فعلنا.
  • تعذّر المصادقة الأساسية في MP3. تعذّر على بعض الأجهزة تحميل ملفات MP3 المحمية بـ Basic Auth، بغض النظر عن المتصفّح الذي تستخدمه.

الاستنتاجات

لقد قطعنا شوطًا طويلاً منذ أن أصبح زر كتم الصوت هو الخيار الأفضل للتعامل مع الصوت على الويب، ولكن هذه ليست سوى البداية، وسيكون الصوت على الويب رائعًا قريبًا. لقد ألقينا نظرة سريعة على الإجراءات التي يمكن اتّخاذها في ما يتعلّق بمزامنة أجهزة متعددة. لم تكن لدينا قدرة المعالجة في الهواتف والأجهزة اللوحية للغوص في معالجة الإشارات والتأثيرات (مثل الصدى)، ولكن مع زيادة أداء الأجهزة، ستستفيد الألعاب المستندة إلى الويب من هذه الميزات أيضًا. نحن في مرحلة مثيرة لمواصلة توسيع نطاق إمكانات الصوت.