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

Hongchan Choi

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

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

סיכום: רכיב אודיו

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

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

דפוסי עיצוב

שימוש ב-Audio Worklet עם WebAssembly

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

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

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

הגדרה

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

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

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

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

כדי שתבנית א' תפעל כמו שצריך, ל-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: שימוש בהעברה בין חוטים של ה-constructor של AudioWorkletNode
תבנית להצגת מודול WASM ב: שימוש בהעברה בין חוטים של ה-constructor של AudioWorkletNode

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

נתוני אודיו ו-WASM Heap

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

הכיתה HeapAudioBuffer לשימוש קל יותר בערימה (heap) של WASM
הקלאס HeapAudioBuffer לשימוש קל יותר ב-heap של WASM

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

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

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

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

שימוש ב-RingBuffer בתוך השיטה process()‎ של AudioWorkletProcessor
שימוש ב-RingBuffer בתוך שיטת process()‎ של AudioWorkletProcessor

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

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

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

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

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

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

הכיתה RingBuffer מופיעה כאן.

כלי העבודה החזקים של WebAudio: Audio Worklet ו-SharedArrayBuffer

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

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

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

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

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

מצבים משותפים ואטומיים

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

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

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

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

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

הגדרה והפעלה

כפי שמוצג בתרשים שלמעלה, לעיצוב הזה יש כמה רכיבים שצריך לסדר: DedicatedWorkerGlobalScope‏ (DWGS), ‏ AudioWorkletGlobalScope‏ (AWGS),‏ SharedArrayBuffer והשרשור הראשי. בשלבים הבאים מוסבר מה אמור לקרות בשלב האינטליקציה.

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

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

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

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

סיכום

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

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

זיכויים

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