תבנית עיצוב של worklet של אודיו

Hongchan Choi

המאמר הקודם בנושא Audio Worklet פירט את המושגים הבסיסיים ואת השימוש בהם. מאז ההשקה ב-Chrome 66 כבר קיבלנו בקשות רבות לדוגמאות נוספות לשימוש באפליקציות בפועל. Audio Worklet חושף את הפוטנציאל המלא של WebAudio, אבל יכול להיות מאתגר לנצל אותו כי הוא דורש הבנה של תכנות בו-זמנית שמוקף בכמה ממשקי API של JS. גם למפתחים שמכירים את WebAudio יכול להיות קשה לשלב את Audio worklet עם ממשקי API אחרים (כמו WebAssembly).

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

סיכום: worklet של אודיו

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

  • BaseAudioContext: האובייקט הראשי של Web Audio API.
  • Worklet של אודיו: טוען קובץ סקריפט מיוחד לפעולת Worklet של אודיו. שייך ל-BaseAudioContext. ל-BaseAudioContext יכול להיות worklet אחד של אודיו. קובץ הסקריפט שנטען מוערך בקטע AudioWorkletGlobalScope ומשמש ליצירת המכונות של AudioWorkletProcessor.
  • AudioWorkletGlobalScope : היקף גלובלי מיוחד של JS בשביל הפעולה של Audio Worklet. פועל ב-thread ייעודי לעיבוד של ה-WebAudio. ל-BaseAudioContext יכול להיות אודיו אחד מסוג AudioWorkletGlobalScope.
  • AudioWorkletNode : AudioNode שמיועד לפעולת worklet של אודיו. נוצר מ-BaseAudioContext. ל-BaseAudioContext יכולים להיות מספר AudioWorkletNodes בדומה ל-AudioNodes המקורי.
  • AudioWorkletProcessor : מקביל של AudioWorkletNode. האופי בפועל של AudioWorkletNode שמעבד את שידור האודיו באמצעות הקוד שסופק על ידי המשתמש. הוא נוצר ב- AudioWorkletGlobalScope כשיוצרים AudioWorkletNode. ל-AudioWorkletNode יכול להיות ערך תואם אחד של AudioWorkletProcessor אחד.

תבניות עיצוב

שימוש ב-Worklet של אודיו עם WebAssembly

WebAssembly היא כלי עזר מושלם ל-AudioWorkletProcessor. השילוב של שתי התכונות האלה מספק מגוון יתרונות לעיבוד אודיו באינטרנט, אבל שני היתרונות הגדולים ביותר הם: א) הוספה של קוד עיבוד אודיו קיים של C/C++ לסביבה העסקית של WebAudio, וב) הימנעות מהתקורה של הידור של JS JIT ואיסוף אשפה בקוד עיבוד האודיו.

הסוג הראשון חשוב למפתחים שמשקיעים בקוד לעיבוד אודיו ובספריות, אבל השני הוא קריטי כמעט לכל המשתמשים ב-API. בעולם WebAudio, תקציב התזמון של שידור האודיו היציב הוא תובעני למדי: מדובר ב-3 אלפיות שנייה בלבד בקצב דגימה של 44.1Khz. גם שיבוש קל בקוד של עיבוד האודיו עלול לגרום לתקלות. המפתח צריך לבצע אופטימיזציה של הקוד לצורך עיבוד מהיר יותר, אבל גם לצמצם את כמות האשפה שנוצרת ב-JS. השימוש ב-WebAssembly יכול להיות פתרון שמטפל בשתי הבעיות בו-זמנית: הוא מהיר יותר ולא יוצר אשפה מהקוד.

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

הגדרה

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

  1. כדי ליצור מודול WebAssembly, צריך לטעון את קוד השיוך ל-AudioWorkletGlobalScope באמצעות audioContext.audioWorklet.addModule().
  2. יוצרים מודול WebAssembly בהיקף הראשי, ואז מעבירים את המודול דרך אפשרויות הבנאי של AudioWorkletNode.

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

תבנית מופע של מודול WebAssembly א: שימוש בקריאה ל- .addModule()
תבנית מופע של מודול WebAssembly א: באמצעות קריאה ל-.addModule()

כדי שהדפוס A יפעל כמו שצריך, Emscripten צריך כמה אפשרויות כדי ליצור את קוד השיוך הנכון של WebAssembly להגדרה שלנו:

-s BINARYEN_ASYNC_COMPILATION=0 -s SINGLE_FILE=1 --post-js mycode.js

בעזרת האפשרויות האלה יתבצע הידור סינכרוני של מודול WebAssembly ב-AudioWorkletGlobalScope. היא גם מצרפת את הגדרת המחלקה של AudioWorkletProcessor ב-mycode.js, כך שאפשר יהיה לטעון אותה אחרי אתחול המודול. הסיבה העיקרית לשימוש בקומפילציה הסינכרונית היא שהרזולוציה ההבטחה של audioWorklet.addModule() לא ממתינה להיפתר של ההבטחות ב-AudioWorkletGlobalScope. בדרך כלל לא מומלץ לבצע טעינה או הידור סינכרוניים ב-thread הראשי כי הם חוסמים את המשימות האחרות באותו ה-thread, אבל כאן אנחנו יכולים לעקוף את הכלל כי ההידור מתרחש ב-AudioWorkletGlobalScope, שרץ מה-thread הראשי. (למידע נוסף, קראו את המאמר הזה).

דפוס יצירה של מודול WASM B: שימוש בהעברה של שרשורים (Cross-thread) של AudioWorkletNode
דפוס יצירה של מודול WASM B: שימוש בהעברה ב-threads של AudioWorkletNode

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

נתוני ערימה ואודיו של WASM

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

כיתת HeapAudioBuffer לשימוש קל יותר בערימה של WASM
כיתת HeapAudioBuffer לשימוש קל יותר בערימה של WASM

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

טיפול בחוסר התאמה של גודל מאגר הנתונים הזמני

צמד AudioWorkletNode ו-AudioWorkletProcessor מיועד לפעול כמו AudioNode רגיל. AudioWorkletNode מטפל באינטראקציה עם קודים אחרים בזמן ש-AudioWorkletProcessor זה מטפל בעיבוד אודיו פנימי. מכיוון ש-AudioNode רגיל מעבד 128 פריימים בכל פעם, AudioWorkletProcessor צריך לבצע את אותו הדבר כדי להפוך לתכונה עיקרית. זה אחד מהיתרונות של עיצוב Audio Worklet שמבטיח שלא יהיו זמן אחזור נוסף בגלל אגירת נתונים פנימית ב-AudioWorkletProcessor, אבל זו יכולה להיות בעיה אם פונקציית עיבוד דורשת גודל מאגר נתונים זמני שונה מ-128 פריימים. הפתרון הנפוץ במקרים כאלה הוא להשתמש במאגר נתונים זמני, שנקרא גם מאגר נתונים עגול או FIFO.

בתרשים הבא מוצג תרשים של AudioWorkletProcessor באמצעות שני מאגרי אחסון זמניים בפנים, שיתאימו לפונקציית WASM שמקבלת 512 פריימים פנימה והחוצה. (המספר 512 כאן נבחר באופן שרירותי).

שימוש ב-RingBuffer בשיטת 'processing() ' של AudioWorkletProcessor
שימוש ב-RingBuffer בשיטת 'processing() ' של AudioWorkletProcessor

האלגוריתם של הדיאגרמה יהיה:

  1. AudioWorkletProcessor, 128 פריימים דוחפים 128 פריימים ל-input RingBuffer מהקלט שלו.
  2. יש לבצע את השלבים הבאים רק אם ל-input RingBuffer יש 512 פריימים ומעלה או שווה לו.
    1. נשלף 512 פריימים מ-input RingBuffer.
    2. מעבדים 512 פריימים עם פונקציית WASM הנתונה.
    3. דוחפים 512 פריימים אל פלט RingBuffer.
  3. AudioWorkletProcessor יכולה לשלוף 128 פריימים מ-Output RingBuffer כדי למלא את הפלט שלו.

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

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

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

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

ניתן למצוא את המחלקה RingBuffer כאן.

WebAudio Powerhouse: Audio Worklet ו-SharedArrayBuffer

דפוס העיצוב האחרון במאמר הזה הוא לשים במקום אחד כמה ממשקי API חדשניים; Audio Worklet, SharedArrayBuffer, Atomics ו-Worker. ההגדרה הלא-חשובה הזו פותחת את הנתיב לתוכנות אודיו קיימות שנכתבו ב-C/C++ לפעול בדפדפן אינטרנט, תוך שמירה על חוויית משתמש חלקה.

סקירה של דפוס העיצוב האחרון: Audio Worklet, SharedArrayBuffer ו-Worker
סקירה כללית של דפוס העיצוב האחרון: Audio Worklet, SharedArrayBuffer ו-Worker

היתרון הכי גדול של העיצוב הוא האפשרות להשתמש ב-DedicatedWorkerGlobalScope רק לעיבוד אודיו. ב-Chrome, WorkerGlobalScope פועל ב-thread בעדיפות נמוכה יותר משרשור העיבוד של WebAudio, אבל יש לו כמה יתרונות בהשוואה ל-AudioWorkletGlobalScope. DedicatedWorkerGlobalScope מוגבל פחות מבחינת הפלטפורמה של ה-API הזמינה בהיקף. בנוסף, התמיכה ב-Emscripten צפויה להיות תמיכה טובה יותר, כי Worker API קיים כבר כמה שנים.

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

מנקודת המבט של ה-API של Web Audio API, העיצוב הזה עשוי להיראות לא אופטימלי, כי הוא משתמש ב-Audio Worklet בתור 'כיור אודיו' פשוט ועושה את כל מה שב-Worker. אבל בהתחשב בעלות של שכתוב פרויקטים ב-C/C++ ב-JavaScript יכולה להיות משימה בלתי אפשרית או אפילו בלתי אפשרית, זה יכול להיות נתיב ההטמעה היעיל ביותר לפרויקטים כאלה.

מדינות משותפות ותורת האטום

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

מנגנון סנכרון: SharedArrayBuffer ו-Atomics
מנגנון סנכרון: SharedArrayBuffer ו-Atomics

מנגנון סנכרון: SharedArrayBuffer ו-Atomics

כל שדה במערך המדינות מייצג מידע חיוני על המאגרים המשותפים. השדה החשוב ביותר הוא שדה לסנכרון (REQUEST_RENDER). הרעיון הוא שה-Worker ימתין עד שהשדה הזה ייגע ב-AudioWorkletProcessor ומעבד את האודיו כשהוא מתעורר. בנוסף ל-SharedArrayBuffer (SAB), המנגנון הזה מתאפשר על ידי Atomics API.

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

הגדרה והרצה

כפי שמוצג בתרשים שלמעלה, העיצוב כולל מספר רכיבים שניתן לארגן: DedicatedWorkerGlobalScope (DWGS), AudioWorkletGlobalScope (AWGS), SharedArrayBuffer וה-thread הראשי. בשלבים הבאים מוסבר מה אמור לקרות בשלב האתחול.

אתחול
  1. [ראשי] קריאה ל-constructor של AudioWorkletNode.
    1. Create Worker.
    2. המערכת תיצור את AudioWorkletProcessor המשויך.
  2. [DWGS] העובד יוצר 2 SharedArrayBuffers. (אחד למצבים משותפים והשני לנתוני אודיו)
  3. [DWGS] העובד שולח הפניות SharedArrayBuffer ל-AudioWorkletNode.
  4. [ראשי] AudioWorkletNode שולח הפניות SharedArrayBuffer ל-AudioWorkletProcessor.
  5. [AWGS] AudioWorkletProcessor מודיע ל-AudioWorkletNode שההגדרה הושלמה.

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

לולאת רינדור
עיבוד מרובה שרשורים באמצעות SharedArrayBuffers
עיבוד מרובה שרשורים עם SharedArrayBuffers
  1. [AWGS] מתבצעת קריאה ל-AudioWorkletProcessor.process(inputs, outputs) לכל קוונטית רינדור.
    1. inputs יועבר אל קלט SAB.
    2. השדה outputs ימולא על ידי צריכה של נתוני אודיו בפלט SAB.
    3. מעדכן את מדינות SAB עם אינדקסים חדשים של מאגר נתונים זמני בהתאם.
    4. אם פלט SAB מתקרב לסף התחתון, ה-Wake Worker יעבד יותר נתוני אודיו.
  2. [DWGS] העובד ממתין (שינה) לאות מצב השינה מ-AudioWorkletProcessor.process(). כשהשעון מתעורר:
    1. מאחזר אינדקסים של מאגר נתונים זמני מ-States SAB (מצב SAB של מדינות).
    2. מריצים את פונקציית התהליך עם נתונים מהקלט SAB כדי למלא את פלט SAB.
    3. מעדכן את מדינות SAB עם אינדקסים של מאגר נתונים זמני בהתאם.
    4. עוברים למצב שינה וממתינים לאות הבא.

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

סיכום

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

למרבה המזל, הסיבה למורכבות כזו היא רק העצמת המפתחים. אם משתמשים ב-WebAssembly כדי להריץ את AudioWorkletGlobalScope, נהנים מפוטנציאל עצום לעיבוד אודיו באינטרנט באיכות גבוהה. אפליקציות אודיו גדולות שכותבות ב-C או ב-C++ יכולות להיות אפשרות אטרקטיבית לשימוש ב-Worklet של אודיו עם SharedArrayBuffers ו-Workers.

זיכויים

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