WebAssembly / البرنامج التعليمي حول الصدأ: معالجة صوت مثالية

نشرت: 2022-03-11

بدعم من جميع المتصفحات الحديثة ، يعمل WebAssembly (أو "Wasm") على تغيير الطريقة التي نطور بها تجارب المستخدم للويب. إنه تنسيق ثنائي بسيط قابل للتنفيذ يسمح للمكتبات أو حتى البرامج الكاملة التي تمت كتابتها بلغات برمجة أخرى بالعمل في متصفح الويب.

غالبًا ما يبحث المطورون عن طرق ليكونوا أكثر إنتاجية ، مثل:

  • استخدام قاعدة كود تطبيق واحدة لأنظمة أساسية متعددة مستهدفة ، ولكن تشغيل التطبيق جيدًا على كل منها
  • إنشاء تجربة مستخدم سلسة وجميلة على بيئات سطح المكتب والجوال
  • الاستفادة من النظام البيئي للمكتبة مفتوحة المصدر لتجنب "إعادة اختراع العجلة" أثناء تطوير التطبيق

بالنسبة لمطوري الواجهة الأمامية ، يوفر WebAssembly الثلاثة جميعًا ، ويجيب على البحث عن واجهة مستخدم لتطبيق ويب تنافس بالفعل تجربة الهاتف المحمول أو سطح المكتب الأصلي. حتى أنه يسمح باستخدام المكتبات المكتوبة بلغات غير جافا سكريبت ، مثل C ++ أو Go!

في هذا البرنامج التعليمي Wasm / Rust ، سننشئ تطبيقًا بسيطًا للكشف عن درجة الصوت ، مثل موالف الغيتار. سيستخدم الإمكانات الصوتية المضمنة في المتصفح ، ويعمل بمعدل 60 إطارًا في الثانية (FPS) - حتى على الأجهزة المحمولة. لست بحاجة إلى فهم Web Audio API أو حتى أن تكون على دراية بـ Rust لمتابعة هذا البرنامج التعليمي ؛ ومع ذلك ، من المتوقع الراحة مع JavaScript.

ملاحظة: لسوء الحظ ، حتى كتابة هذه السطور ، فإن التقنية المستخدمة في هذه المقالة - خاصة بواجهة برمجة تطبيقات Web Audio - لا تعمل حتى الآن في Firefox. لذلك ، في الوقت الحالي ، يوصى باستخدام Chrome أو Chromium أو Edge لهذا البرنامج التعليمي ، على الرغم من دعم Wasm و Web Audio API الممتاز في Firefox.

ماذا يغطي هذا البرنامج التعليمي WebAssembly / Rust

  • إنشاء وظيفة بسيطة في Rust واستدعائها من JavaScript (عبر WebAssembly)
  • استخدام واجهة برمجة تطبيقات AudioWorklet الحديثة للمتصفح لمعالجة الصوت عالية الأداء في المتصفح
  • التواصل بين العاملين في JavaScript
  • ربط كل ذلك معًا في تطبيق React

ملاحظة: إذا كنت مهتمًا بـ "كيف" أكثر من "لماذا" في هذه المقالة ، فلا تتردد في الانتقال مباشرة إلى البرنامج التعليمي.

لماذا واسم؟

هناك عدة أسباب تجعل من المنطقي استخدام WebAssembly:

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

لماذا لا تستخدم دائما Wasm؟

ستستمر شعبية WebAssembly بالتأكيد في النمو ؛ ومع ذلك ، فهي ليست مناسبة لجميع عمليات تطوير الويب:

  • بالنسبة للمشروعات البسيطة ، من المحتمل أن يؤدي الالتزام باستخدام JavaScript و HTML و CSS إلى توفير منتج فعال في وقت أقصر.
  • لا تدعم المتصفحات القديمة مثل Internet Explorer Wasm بشكل مباشر.
  • تتطلب الاستخدامات النموذجية لـ WebAssembly إضافة أدوات ، مثل مترجم اللغة ، إلى سلسلة الأدوات الخاصة بك. إذا أعطى فريقك الأولوية للحفاظ على أدوات التطوير والتكامل المستمر بسيطة قدر الإمكان ، فإن استخدام Wasm سيعكس ذلك.

لماذا دروس Wasm / Rust ، على وجه التحديد؟

في حين أن العديد من لغات البرمجة تترجم إلى Wasm ، فقد اخترت Rust لهذا المثال. تم إنشاء Rust بواسطة Mozilla في عام 2010 وتزداد شعبيته. يحتل Rust صدارة "اللغة الأكثر شعبية" في استطلاع مطور 2020 من Stack Overflow. لكن أسباب استخدام Rust مع WebAssembly تتجاوز مجرد الاتجاه:

  • أولاً وقبل كل شيء ، يتمتع Rust بوقت تشغيل صغير مما يعني أنه يتم إرسال رمز أقل إلى المتصفح عندما يصل المستخدم إلى الموقع ، مما يساعد على إبقاء بصمة موقع الويب منخفضة.
  • يتمتع Rust بدعم Wasm ممتاز ، مما يدعم قابلية التشغيل التفاعلي عالية المستوى مع JavaScript.
  • يوفر Rust أداءً قريبًا من مستوى C / C ++ ، إلا أنه يحتوي على نموذج ذاكرة آمن للغاية . عند مقارنتها باللغات الأخرى ، يقوم Rust بإجراء فحوصات أمان إضافية أثناء تجميع التعليمات البرمجية الخاصة بك ، مما يقلل بشكل كبير من احتمالية حدوث أعطال ناجمة عن المتغيرات الفارغة أو غير المهيأة. يمكن أن يؤدي ذلك إلى معالجة أبسط للأخطاء وفرصة أكبر للحفاظ على تجربة مستخدم جيدة عند حدوث مشكلات غير متوقعة.
  • الصدأ لا يتم جمع القمامة . هذا يعني أن كود Rust يتحكم بشكل كامل في وقت تخصيص الذاكرة وتنظيفها ، مما يسمح بأداء ثابت - وهو مطلب رئيسي في أنظمة الوقت الفعلي.

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

أداء WebAssembly: الحفاظ على تطبيقات الويب فائقة النعومة

نظرًا لأننا نبرمج في WebAssembly مع Rust ، كيف يمكننا استخدام Rust لاكتساب مزايا الأداء التي قادتنا إلى Wasm في المقام الأول؟ لكي يشعر التطبيق الذي يحتوي على واجهة مستخدم رسومية يتم تحديثها بسرعة "بسلاسة" للمستخدمين ، يجب أن يكون قادرًا على تحديث الشاشة بانتظام مثل أجهزة الشاشة. هذا عادةً 60 إطارًا في الثانية ، لذا يجب أن يكون تطبيقنا قادرًا على إعادة رسم واجهة المستخدم الخاصة به في غضون 16.7 مللي ثانية (1000 مللي ثانية / 60 إطارًا في الثانية).

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

أساسيات صوت الويب

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

لماذا لا نبقي الأمور بسيطة ونقوم باكتشاف درجة الصوت على الخيط الرئيسي؟

  • غالبًا ما تكون معالجة الصوت عملية حسابية مكثفة. هذا بسبب العدد الكبير من العينات التي يجب معالجتها كل ثانية. على سبيل المثال ، يتطلب اكتشاف طبقة الصوت بشكل موثوق تحليل أطياف 44100 عينة كل ثانية.
  • يحدث تجميع JIT وجمع البيانات المهملة لـ JavaScript في السلسلة الرئيسية ، ونريد تجنب ذلك في كود معالجة الصوت للحصول على أداء ثابت.
  • إذا كان الوقت المستغرق لمعالجة إطار الصوت سيؤثر بشكل كبير في ميزانية الإطار البالغة 16.7 مللي ثانية ، فستعاني UX من الرسوم المتحركة المتقطعة.
  • نريد أن يعمل تطبيقنا بسلاسة حتى على الأجهزة المحمولة منخفضة الأداء!

تسمح برامج Web Audio للتطبيقات بمواصلة تحقيق 60 إطارًا في الثانية على نحو سلس لأن معالجة الصوت لا يمكنها إيقاف الخيط الرئيسي. إذا كانت معالجة الصوت بطيئة للغاية وتتأخر ، فستكون هناك تأثيرات أخرى ، مثل الصوت المتأخر. ومع ذلك ، ستظل UX مستجيبة للمستخدم.

WebAssembly / البرنامج التعليمي حول الصدأ: الشروع في العمل

يفترض هذا البرنامج التعليمي أن لديك Node.js مثبتًا ، وكذلك npx . إذا لم يكن لديك npx بالفعل ، فيمكنك استخدام npm (الذي يأتي مع Node.js) لتثبيته:

 npm install -g npx

أنشئ تطبيق ويب

في هذا البرنامج التعليمي Wasm / Rust ، سنستخدم React.

في الطرفية ، سنقوم بتشغيل الأوامر التالية:

 npx create-react-app wasm-audio-app cd wasm-audio-app

يستخدم هذا الأمر npx لتنفيذ الأمر create-react-app (الموجود في الحزمة المقابلة التي يحتفظ بها Facebook) لإنشاء تطبيق React جديد في الدليل wasm-audio-app .

create-react-app هو واجهة سطر الأوامر (CLI) لإنشاء تطبيقات ذات صفحة واحدة تعتمد على React (SPAs). إنه يجعل من السهل للغاية بدء مشروع جديد باستخدام React. ومع ذلك ، يتضمن مشروع الإخراج رمز معياري يجب استبداله.

أولاً ، على الرغم من أنني أوصي بشدة باختبار الوحدة لتطبيقك خلال التطوير ، فإن الاختبار خارج نطاق هذا البرنامج التعليمي. لذلك سنمضي قدمًا ونحذف src/App.test.js و src/setupTests.js .

نظرة عامة على التطبيق

سيكون هناك خمسة مكونات JavaScript رئيسية في تطبيقنا:

  • يحتوي public/wasm-audio/wasm-audio.js على ارتباطات JavaScript بوحدة Wasm التي توفر خوارزمية الكشف عن درجة الصوت.
  • public/PitchProcessor.js هو المكان الذي تحدث فيه معالجة الصوت. يتم تشغيله في مؤشر ترابط تقديم Web Audio وسوف يستهلك Wasm API.
  • يحتوي src/PitchNode.js على تنفيذ عقدة Web Audio ، وهي متصلة بالرسم البياني Web Audio وتعمل في السلسلة الرئيسية.
  • يستخدم src/setupAudio.js واجهات برمجة تطبيقات متصفح الويب للوصول إلى جهاز تسجيل صوتي متاح.
  • src/App.js و src/App.css على واجهة مستخدم التطبيق.

مخطط انسيابي لتطبيق اكتشاف درجة الصوت. يتم تشغيل الكتلتين 1 و 2 في سلسلة Web Audio. Block 1 هو Wasm (Rust) Pitch Detector ، في الملف wasm-audio / lib.rs. Block 2 هو Web Audio Detection + Communication ، في الملف PitchProcessor.js. يطلب من الكاشف التهيئة ، ويرسل الكاشف النغمات المكتشفة مرة أخرى إلى واجهة Web Audio. تعمل المربعات 3 و 4 و 5 على الخيط الرئيسي. Block 3 هو Web Audio Controller ، في الملف PitchNode.js. يقوم بإرسال وحدة Wasm إلى PitchProcessor.js ، ويتلقى منها النغمات المكتشفة. الكتلة 4 هي إعداد Web Audio ، في setupAudio.js. يقوم بإنشاء كائن PitchNode. Block 5 هي واجهة مستخدم تطبيق الويب ، وتتألف من App.js و App.css. تستدعي setupAudio.js عند بدء التشغيل. كما أنه يقوم بإيقاف التسجيل الصوتي مؤقتًا أو استئنافه عن طريق إرسال رسالة إلى PitchNode ، والتي يتلقى منها نغمات تم اكتشافها لعرضها على المستخدم.
نظرة عامة على تطبيق Wasm الصوتي.

دعنا نتعمق في قلب تطبيقنا ونحدد كود Rust لوحدة Wasm الخاصة بنا. سنقوم بعد ذلك بتشفير الأجزاء المختلفة من JavaScript المرتبط بـ Web Audio وننتهي بواجهة المستخدم.

1. كشف الملعب باستخدام الصدأ و WebAssembly

سيحسب كود Rust الخاص بنا نغمة موسيقية من مجموعة من عينات الصوت.

احصل على الصدأ

يمكنك اتباع هذه التعليمات لبناء سلسلة Rust من أجل التطوير.

قم بتثبيت أدوات لبناء مكونات WebAssembly في Rust

يتيح لك wasm-pack إنشاء مكونات WebAssembly التي تم إنشاؤها من خلال الصدأ واختبارها ونشرها. إذا لم تكن قد قمت بذلك بالفعل ، فقم بتثبيت wasm-pack.

تساعد أداة cargo-generate على تشغيل مشروع Rust الجديد من خلال الاستفادة من مستودع Git الموجود مسبقًا كقالب. سنستخدم هذا لتشغيل محلل صوت بسيط في Rust يمكن الوصول إليه باستخدام WebAssembly من المتصفح.

باستخدام أداة cargo المرفقة بسلسلة Rust ، يمكنك تثبيت cargo-generate :

 cargo install cargo-generate

بمجرد اكتمال التثبيت (الذي قد يستغرق عدة دقائق) ، نكون مستعدين لإنشاء مشروع Rust الخاص بنا.

قم بإنشاء وحدة WebAssembly الخاصة بنا

من المجلد الجذر لتطبيقنا ، سنقوم باستنساخ قالب المشروع:

 $ cargo generate --git https://github.com/rustwasm/wasm-pack-template

عندما يُطلب منك اسم مشروع جديد ، سنقوم بإدخال wasm-audio .

في دليل wasm-audio ، سيكون هناك الآن ملف Cargo.toml بالمحتويات التالية:

 [package] name = "wasm-audio" version = "0.1.0" authors = ["Your Name <[email protected]"] edition = "2018" [lib] crate-type = ["cdylib", "rlib"] [features] default = ["console_error_panic_hook"] [dependencies] wasm-bindgen = "0.2.63" ...

يتم استخدام Cargo.toml لتعريف حزمة Rust (التي يطلق عليها Rust اسم "قفص") ، والتي تخدم وظيفة مماثلة لتطبيقات Rust التي package.json لتطبيقات JavaScript.

يحدد قسم [package] البيانات الوصفية التي يتم استخدامها عند نشر الحزمة في سجل الحزمة الرسمي لـ Rust.

يصف القسم [lib] تنسيق الإخراج من عملية تجميع Rust. هنا ، يخبر "cdylib" Rust بإنتاج "مكتبة نظام ديناميكي" يمكن تحميلها من لغة أخرى (في حالتنا ، JavaScript) وتضمين "rlib" يخبر Rust بإضافة مكتبة ثابتة تحتوي على بيانات وصفية حول المكتبة المنتجة. هذا المحدد الثاني ليس ضروريًا لأغراضنا - فهو يساعد في تطوير المزيد من وحدات الصدأ التي تستهلك هذا الصندوق باعتباره تبعية - ولكن من الآمن تركها.

في [features] ، نطلب من Rust تضمين ميزة اختيارية console_error_panic_hook لتوفير وظيفة تحول آلية الأخطاء التي لم تتم معالجتها لـ Rust (تسمى panic ) للتحكم في الأخطاء التي تظهر في أدوات التطوير لتصحيح الأخطاء.

أخيرًا ، تسرد [dependencies] جميع الصناديق التي يعتمد عليها هذا الصندوق. التبعية الوحيدة التي يتم توفيرها خارج الصندوق هي wasm-bindgen ، والتي توفر توليدًا تلقائيًا من ارتباطات JavaScript لوحدة Wasm الخاصة بنا.

استخدم كاشف الملعب في الصدأ

الغرض من هذا التطبيق هو أن تكون قادرًا على اكتشاف صوت الموسيقي أو نغمة الآلة في الوقت الفعلي. لضمان تنفيذ هذا في أسرع وقت ممكن ، تم تكليف وحدة WebAssembly بحساب درجة الصوت. بالنسبة لاكتشاف درجة الصوت الأحادي ، سنستخدم طريقة "McLeod" درجة الصوت التي يتم تنفيذها في مكتبة pitch-detection الصدأ الحالية.

يشبه إلى حد كبير مدير الحزم Node.js (npm) ، يتضمن Rust مدير حزم خاص به ، يسمى Cargo. يتيح ذلك تثبيت الحزم التي تم نشرها في سجل Rust crate بسهولة.

لإضافة التبعية ، قم بتحرير Cargo.toml ، مع إضافة السطر pitch-detection الصوت إلى قسم التبعيات:

 [dependencies] wasm-bindgen = "0.2.63" pitch-detection = "0.1"

هذا يوجه Cargo إلى تنزيل وتثبيت تبعية pitch-detection أثناء cargo build التالي أو ، نظرًا لأننا نستهدف WebAssembly ، فسيتم تنفيذ ذلك في wasm-pack التالية.

قم بإنشاء Pitch Detector القابل للاستدعاء JavaScript في Rust

أولاً سنضيف ملفًا يحدد أداة مفيدة سنناقش غرضها لاحقًا:

أنشئ wasm-audio/src/utils.rs والصق محتويات هذا الملف بداخله.

سنقوم باستبدال الكود الذي تم إنشاؤه في wasm-audio/lib.rs التالي ، والذي يقوم باكتشاف درجة الصوت عبر خوارزمية تحويل فورييه السريع (FFT):

 use pitch_detection::{McLeodDetector, PitchDetector}; use wasm_bindgen::prelude::*; mod utils; #[wasm_bindgen] pub struct WasmPitchDetector { sample_rate: usize, fft_size: usize, detector: McLeodDetector<f32>, } #[wasm_bindgen] impl WasmPitchDetector { pub fn new(sample_rate: usize, fft_size: usize) -> WasmPitchDetector { utils::set_panic_hook(); let fft_pad = fft_size / 2; WasmPitchDetector { sample_rate, fft_size, detector: McLeodDetector::<f32>::new(fft_size, fft_pad), } } pub fn detect_pitch(&mut self, audio_samples: Vec<f32>) -> f32 { if audio_samples.len() < self.fft_size { panic!("Insufficient samples passed to detect_pitch(). Expected an array containing {} elements but got {}", self.fft_size, audio_samples.len()); } // Include only notes that exceed a power threshold which relates to the // amplitude of frequencies in the signal. Use the suggested default // value of 5.0 from the library. const POWER_THRESHOLD: f32 = 5.0; // The clarity measure describes how coherent the sound of a note is. For // example, the background sound in a crowded room would typically be would // have low clarity and a ringing tuning fork would have high clarity. // This threshold is used to accept detect notes that are clear enough // (valid values are in the range 0-1). const CLARITY_THRESHOLD: f32 = 0.6; let optional_pitch = self.detector.get_pitch( &audio_samples, self.sample_rate, POWER_THRESHOLD, CLARITY_THRESHOLD, ); match optional_pitch { Some(pitch) => pitch.frequency, None => 0.0, } } }

دعنا نفحص هذا بمزيد من التفصيل:

 #[wasm_bindgen]

wasm_bindgen هو ماكرو Rust يساعد في تنفيذ الربط بين JavaScript و Rust. عند التحويل البرمجي إلى WebAssembly ، يوجه هذا الماكرو المترجم لإنشاء ارتباط JavaScript بفئة. سيترجم كود Rust أعلاه إلى روابط JavaScript التي هي مجرد أغلفة رقيقة للمكالمات الواردة من وإلى وحدة Wasm. إن الطبقة الخفيفة من التجريد المدمجة مع الذاكرة المشتركة المباشرة بين JavaScript هي ما يساعد Wasm على تقديم أداء ممتاز.

 #[wasm_bindgen] pub struct WasmPitchDetector { sample_rate: usize, fft_size: usize, detector: McLeodDetector<f32>, } #[wasm_bindgen] impl WasmPitchDetector { ... }

الصدأ ليس لديه مفهوم الطبقات. بدلاً من ذلك ، يتم وصف بيانات الكائن من خلال struct وسلوكها من خلال impl أو trait .

لماذا يتم الكشف عن وظيفة الكشف عن درجة الصوت من خلال كائن بدلاً من وظيفة عادية؟ وبهذه الطريقة ، نقوم فقط بتهيئة هياكل البيانات المستخدمة بواسطة McLeodDetector الداخلي مرة واحدة ، أثناء إنشاء WasmPitchDetector . يؤدي ذلك إلى الحفاظ على سرعة وظيفة detect_pitch من خلال تجنب تخصيص الذاكرة المكلف أثناء التشغيل.

 pub fn new(sample_rate: usize, fft_size: usize) -> WasmPitchDetector { utils::set_panic_hook(); let fft_pad = fft_size / 2; WasmPitchDetector { sample_rate, fft_size, detector: McLeodDetector::<f32>::new(fft_size, fft_pad), } }

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

سيضمن استدعاء utils::set_panic_hook() مرة واحدة أثناء الإعداد ظهور رسائل الذعر في أدوات تطوير المتصفح.

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

أخيرًا ، يُرجع Rust نتيجة العبارة الأخيرة تلقائيًا ، لذا فإن WasmPitchDetector Struct هي القيمة المرجعة لـ new() .

يحدد باقي كود impl WasmPitchDetector Rust الضمني واجهة برمجة التطبيقات لاكتشاف النغمات:

 pub fn detect_pitch(&mut self, audio_samples: Vec<f32>) -> f32 { ... }

هذا ما يبدو عليه تعريف دالة العضو في Rust. يتم إضافة WasmPitchDetector detect_pitch الوسيطة الأولى هي مرجع قابل للتغيير ( &mut ) لكائن تم إنشاء مثيل له من نفس النوع يحتوي على struct impl - ولكن يتم تمرير هذا تلقائيًا عند الاتصال ، كما سنرى أدناه.

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

 if audio_samples.len() < self.fft_size { panic!("Insufficient samples passed to detect_pitch(). Expected an array containing {} elements but got {}", self.fft_size, audio_samples.len()); }

يكتشف الكود أعلاه ما إذا كان قد تم تقديم عينات كافية للوظيفة من أجل إجراء تحليل درجة صوتي صالح. إذا لم يكن كذلك ، فهل الصدأ panic! يسمى الماكرو الذي ينتج عنه الخروج الفوري من Wasm ورسالة الخطأ المطبوعة على وحدة تحكم أدوات تطوير المتصفح.

 let optional_pitch = self.detector.get_pitch( &audio_samples, self.sample_rate, POWER_THRESHOLD, CLARITY_THRESHOLD, );

هذا يستدعي مكتبة الطرف الثالث لحساب درجة الصوت من أحدث عينات الصوت. يمكن ضبط POWER_THRESHOLD و CLARITY_THRESHOLD لضبط حساسية الخوارزمية.

ننتهي بإرجاع ضمني لقيمة النقطة العائمة عبر الكلمة الرئيسية match ، والتي تعمل بشكل مشابه لبيان switch في اللغات الأخرى. تسمح لنا Some() و None بمعالجة الحالات بشكل مناسب دون الوقوع في استثناء مؤشر فارغ.

بناء تطبيقات WebAssembly

عند تطوير تطبيقات Rust ، فإن إجراء البناء المعتاد هو استدعاء بناء باستخدام cargo build . ومع ذلك ، فإننا نقوم بإنشاء وحدة Wasm ، لذلك سنستخدم wasm-pack ، والتي توفر صياغة أبسط عند استهداف Wasm. (يسمح أيضًا بنشر روابط JavaScript الناتجة في سجل npm ، ولكن هذا خارج نطاق هذا البرنامج التعليمي.)

تدعم wasm-pack مجموعة متنوعة من أهداف البناء. نظرًا لأننا سنستهلك الوحدة مباشرة من برنامج Web Audio ، فإننا سنستهدف خيار web . تشمل الأهداف الأخرى البناء لحزمة مثل webpack أو للاستهلاك من Node.js. سنقوم بتشغيل هذا من دليل فرعي wasm-audio/ :

 wasm-pack build --target web

إذا نجحت ، يتم إنشاء وحدة npm ضمن ./pkg .

هذه وحدة جافا سكريبت تحتوي على package.json الخاصة بها والتي يتم إنشاؤها تلقائيًا. يمكن نشر هذا في سجل npm إذا رغبت في ذلك. لتبسيط الأمور في الوقت الحالي ، يمكننا ببساطة نسخ ولصق ملف pkg هذا ضمن مجلدنا public/wasm-audio :

 cp -R ./wasm-audio/pkg ./public/wasm-audio

من خلال ذلك ، أنشأنا وحدة Rust Wasm جاهزة للاستهلاك بواسطة تطبيق الويب ، أو بشكل أكثر تحديدًا ، بواسطة PitchProcessor .

2. فئة PitchProcessor بنا (بناءً على AudioWorkletProcessor الأصلي)

بالنسبة لهذا التطبيق ، سنستخدم معيار معالجة الصوت الذي اكتسب مؤخرًا توافقًا واسع النطاق للمتصفح. على وجه التحديد ، سنستخدم Web Audio API ونجري عمليات حسابية باهظة الثمن في AudioWorkletProcessor المخصص. بعد ذلك سننشئ فئة AudioWorkletNode المخصصة المقابلة (والتي PitchNode ) كجسر يعود إلى السلسلة الرئيسية.

أنشئ ملفًا جديدًا public/PitchProcessor.js والصق الكود التالي فيه:

 import init, { WasmPitchDetector } from "./wasm-audio/wasm_audio.js"; class PitchProcessor extends AudioWorkletProcessor { constructor() { super(); // Initialized to an array holding a buffer of samples for analysis later - // once we know how many samples need to be stored. Meanwhile, an empty // array is used, so that early calls to process() with empty channels // do not break initialization. this.samples = []; this.totalSamples = 0; // Listen to events from the PitchNode running on the main thread. this.port.onmessage = (event) => this.onmessage(event.data); this.detector = null; } onmessage(event) { if (event.type === "send-wasm-module") { // PitchNode has sent us a message containing the Wasm library to load into // our context as well as information about the audio device used for // recording. init(WebAssembly.compile(event.wasmBytes)).then(() => { this.port.postMessage({ type: 'wasm-module-loaded' }); }); } else if (event.type === 'init-detector') { const { sampleRate, numAudioSamplesPerAnalysis } = event; // Store this because we use it later to detect when we have enough recorded // audio samples for our first analysis. this.numAudioSamplesPerAnalysis = numAudioSamplesPerAnalysis; this.detector = WasmPitchDetector.new(sampleRate, numAudioSamplesPerAnalysis); // Holds a buffer of audio sample values that we'll send to the Wasm module // for analysis at regular intervals. this.samples = new Array(numAudioSamplesPerAnalysis).fill(0); this.totalSamples = 0; } }; process(inputs, outputs) { // inputs contains incoming audio samples for further processing. outputs // contains the audio samples resulting from any processing performed by us. // Here, we are performing analysis only to detect pitches so do not modify // outputs. // inputs holds one or more "channels" of samples. For example, a microphone // that records "in stereo" would provide two channels. For this simple app, // we use assume either "mono" input or the "left" channel if microphone is // stereo. const inputChannels = inputs[0]; // inputSamples holds an array of new samples to process. const inputSamples = inputChannels[0]; // In the AudioWorklet spec, process() is called whenever exactly 128 new // audio samples have arrived. We simplify the logic for filling up the // buffer by making an assumption that the analysis size is 128 samples or // larger and is a power of 2. if (this.totalSamples < this.numAudioSamplesPerAnalysis) { for (const sampleValue of inputSamples) { this.samples[this.totalSamples++] = sampleValue; } } else { // Buffer is already full. We do not want the buffer to grow continually, // so instead will "cycle" the samples through it so that it always // holds the latest ordered samples of length equal to // numAudioSamplesPerAnalysis. // Shift the existing samples left by the length of new samples (128). const numNewSamples = inputSamples.length; const numExistingSamples = this.samples.length - numNewSamples; for (let i = 0; i < numExistingSamples; i++) { this.samples[i] = this.samples[i + numNewSamples]; } // Add the new samples onto the end, into the 128-wide slot vacated by // the previous copy. for (let i = 0; i < numNewSamples; i++) { this.samples[numExistingSamples + i] = inputSamples[i]; } this.totalSamples += inputSamples.length; } // Once our buffer has enough samples, pass them to the Wasm pitch detector. if (this.totalSamples >= this.numAudioSamplesPerAnalysis && this.detector) { const result = this.detector.detect_pitch(this.samples); if (result !== 0) { this.port.postMessage({ type: "pitch", pitch: result }); } } // Returning true tells the Audio system to keep going. return true; } } registerProcessor("PitchProcessor", PitchProcessor);

يعد PitchProcessor مصاحبًا لـ PitchNode ولكنه يعمل في سلسلة منفصلة بحيث يمكن إجراء حساب معالجة الصوت دون حظر العمل المنجز على الخيط الرئيسي.

بشكل أساسي ، PitchProcessor :

  • يتعامل مع حدث "send-wasm-module" الذي تم إرساله من PitchNode عن طريق تجميع وحدة Wasm وتحميلها في الكتيب. بمجرد الانتهاء من ذلك ، فإنه يتيح PitchNode معرفة ذلك عن طريق إرسال حدث "wasm-module-loaded" . نهج رد الاتصال هذا مطلوب لأن كل الاتصالات بين PitchNode و PitchProcessor حدود مؤشر الترابط ولا يمكن إجراؤها بشكل متزامن.
  • يستجيب أيضًا لحدث "init-detector" من PitchNode عن طريق تكوين WasmPitchDetector .
  • يعالج عينات الصوت الواردة من الرسم البياني الصوتي للمتصفح ، ويفوض حساب اكتشاف درجة الصوت إلى وحدة Wasm ، ثم يرسل أي خطوة تم اكتشافها مرة أخرى إلى PitchNode (التي ترسل درجة الصوت على طول طبقة React عبر onPitchDetectedCallback ).
  • يسجل نفسه باسم محدد وفريد. بهذه الطريقة يعرف المتصفح - من خلال الفئة الأساسية لـ PitchNode ، AudioWorkletNode الأصلي - كيفية إنشاء مثيل PitchProcessor لاحقًا عند إنشاء PitchNode . انظر setupAudio.js .

الرسم البياني التالي يصور تدفق الأحداث بين PitchNode و PitchProcessor :

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

3. إضافة رمز Web Audio Worklet

يوفر PitchNode.js واجهة لمعالجة الصوت المخصصة لاكتشاف درجة الصوت. كائن PitchNode هو الآلية التي يتم من خلالها اكتشاف النغمات باستخدام وحدة WebAssembly التي تعمل في مؤشر ترابط AudioWorklet طريقها إلى مؤشر الترابط الرئيسي و React للعرض.

في src/PitchNode.js ، سنصنف فئة فرعية AudioWorkletNode المضمنة لواجهة برمجة تطبيقات Web Audio:

 export default class PitchNode extends AudioWorkletNode { /** * Initialize the Audio processor by sending the fetched WebAssembly module to * the processor worklet. * * @param {ArrayBuffer} wasmBytes Sequence of bytes representing the entire * WASM module that will handle pitch detection. * @param {number} numAudioSamplesPerAnalysis Number of audio samples used * for each analysis. Must be a power of 2. */ init(wasmBytes, onPitchDetectedCallback, numAudioSamplesPerAnalysis) { this.onPitchDetectedCallback = onPitchDetectedCallback; this.numAudioSamplesPerAnalysis = numAudioSamplesPerAnalysis; // Listen to messages sent from the audio processor. this.port.onmessage = (event) => this.onmessage(event.data); this.port.postMessage({ type: "send-wasm-module", wasmBytes, }); } // Handle an uncaught exception thrown in the PitchProcessor. onprocessorerror(err) { console.log( `An error from AudioWorkletProcessor.process() occurred: ${err}` ); }; onmessage(event) { if (event.type === 'wasm-module-loaded') { // The Wasm module was successfully sent to the PitchProcessor running on the // AudioWorklet thread and compiled. This is our cue to configure the pitch // detector. this.port.postMessage({ type: "init-detector", sampleRate: this.context.sampleRate, numAudioSamplesPerAnalysis: this.numAudioSamplesPerAnalysis }); } else if (event.type === "pitch") { // A pitch was detected. Invoke our callback which will result in the UI updating. this.onPitchDetectedCallback(event.pitch); } } }

المهام الرئيسية التي تؤديها PitchNode هي:

  • أرسل وحدة WebAssembly على شكل تسلسل من البايت الخام - تلك التي تم تمريرها من setupAudio.js - إلى PitchProcessor ، والذي يتم تشغيله على مؤشر ترابط AudioWorklet . هذه هي الطريقة التي يقوم بها PitchProcessor بتحميل وحدة Wasm للكشف عن درجة الصوت.
  • تعامل مع الحدث الذي أرسله PitchProcessor عندما نجح في تجميع Wasm ، وأرسل إليه حدثًا آخر يمرر معلومات تكوين اكتشاف درجة الصوت إليه.
  • تعامل مع النغمات المكتشفة عند وصولها من PitchProcessor وأعد توجيهها إلى مجموعة وظائف setLatestPitch() عبر onPitchDetectedCallback() .

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

4. أضف رمزًا لإعداد Web Audio

لكي يتمكن تطبيق الويب من الوصول إلى الإدخال المباشر ومعالجته من ميكروفون جهاز العميل ، يجب أن:

  1. احصل على إذن المستخدم للمتصفح للوصول إلى أي ميكروفون متصل
  2. قم بالوصول إلى خرج الميكروفون ككائن دفق صوتي
  3. قم بإرفاق رمز لمعالجة عينات التدفق الصوتي الواردة وإنتاج سلسلة من النغمات المكتشفة

في src/setupAudio.js ، سنفعل ذلك ، ونحمل أيضًا وحدة Wasm بشكل غير متزامن حتى نتمكن من تهيئة PitchNode بها ، قبل إرفاق PitchNode:

 import PitchNode from "./PitchNode"; async function getWebAudioMediaStream() { if (!window.navigator.mediaDevices) { throw new Error( "This browser does not support web audio or it is not enabled." ); } try { const result = await window.navigator.mediaDevices.getUserMedia({ audio: true, video: false, }); return result; } catch (e) { switch (e.name) { case "NotAllowedError": throw new Error( "A recording device was found but has been disallowed for this application. Enable the device in the browser settings." ); case "NotFoundError": throw new Error( "No recording device was found. Please attach a microphone and click Retry." ); default: throw e; } } } export async function setupAudio(onPitchDetectedCallback) { // Get the browser audio. Awaits user "allowing" it for the current tab. const mediaStream = await getWebAudioMediaStream(); const context = new window.AudioContext(); const audioSource = context.createMediaStreamSource(mediaStream); let node; try { // Fetch the WebAssembly module that performs pitch detection. const response = await window.fetch("wasm-audio/wasm_audio_bg.wasm"); const wasmBytes = await response.arrayBuffer(); // Add our audio processor worklet to the context. const processorUrl = "PitchProcessor.js"; try { await context.audioWorklet.addModule(processorUrl); } catch (e) { throw new Error( `Failed to load audio analyzer worklet at url: ${processorUrl}. Further info: ${e.message}` ); } // Create the AudioWorkletNode which enables the main JavaScript thread to // communicate with the audio processor (which runs in a Worklet). node = new PitchNode(context, "PitchProcessor"); // numAudioSamplesPerAnalysis specifies the number of consecutive audio samples that // the pitch detection algorithm calculates for each unit of work. Larger values tend // to produce slightly more accurate results but are more expensive to compute and // can lead to notes being missed in faster passages ie where the music note is // changing rapidly. 1024 is usually a good balance between efficiency and accuracy // for music analysis. const numAudioSamplesPerAnalysis = 1024; // Send the Wasm module to the audio node which in turn passes it to the // processor running in the Worklet thread. Also, pass any configuration // parameters for the Wasm detection algorithm. node.init(wasmBytes, onPitchDetectedCallback, numAudioSamplesPerAnalysis); // Connect the audio source (microphone output) to our analysis node. audioSource.connect(node); // Connect our analysis node to the output. Required even though we do not // output any audio. Allows further downstream audio processing or output to // occur. node.connect(context.destination); } catch (err) { throw new Error( `Failed to load audio analyzer WASM module. Further info: ${err.message}` ); } return { context, node }; }

This assumes a WebAssembly module is available to be loaded at public/wasm-audio , which we accomplished in the earlier Rust section.

5. Define the Application UI

Let's define a basic user interface for the pitch detector. We'll replace the contents of src/App.js with the following code:

 import React from "react"; import "./App.css"; import { setupAudio } from "./setupAudio"; function PitchReadout({ running, latestPitch }) { return ( <div className="Pitch-readout"> {latestPitch ? `Latest pitch: ${latestPitch.toFixed(1)} Hz` : running ? "Listening..." : "Paused"} </div> ); } function AudioRecorderControl() { // Ensure the latest state of the audio module is reflected in the UI // by defining some variables (and a setter function for updating them) // that are managed by React, passing their initial values to useState. // 1. audio is the object returned from the initial audio setup that // will be used to start/stop the audio based on user input. While // this is initialized once in our simple application, it is good // practice to let React know about any state that _could_ change // again. const [audio, setAudio] = React.useState(undefined); // 2. running holds whether the application is currently recording and // processing audio and is used to provide button text (Start vs Stop). const [running, setRunning] = React.useState(false); // 3. latestPitch holds the latest detected pitch to be displayed in // the UI. const [latestPitch, setLatestPitch] = React.useState(undefined); // Initial state. Initialize the web audio once a user gesture on the page // has been registered. if (!audio) { return ( <button onClick={async () => { setAudio(await setupAudio(setLatestPitch)); setRunning(true); }} > Start listening </button> ); } // Audio already initialized. Suspend / resume based on its current state. const { context } = audio; return ( <div> <button onClick={async () => { if (running) { await context.suspend(); setRunning(context.state === "running"); } else { await context.resume(); setRunning(context.state === "running"); } }} disabled={context.state !== "running" && context.state !== "suspended"} > {running ? "Pause" : "Resume"} </button> <PitchReadout running={running} latestPitch={latestPitch} /> </div> ); } function App() { return ( <div className="App"> <header className="App-header"> Wasm Audio Tutorial </header> <div className="App-content"> <AudioRecorderControl /> </div> </div> ); } export default App;

And we'll replace App.css with some basic styles:

 .App { display: flex; flex-direction: column; align-items: center; text-align: center; background-color: #282c34; min-height: 100vh; color: white; justify-content: center; } .App-header { font-size: 1.5rem; margin: 10%; } .App-content { margin-top: 15vh; height: 85vh; } .Pitch-readout { margin-top: 5vh; font-size: 3rem; } button { background-color: rgb(26, 115, 232); border: none; outline: none; color: white; margin: 1em; padding: 10px 14px; border-radius: 4px; width: 190px; text-transform: capitalize; cursor: pointer; font-size: 1.5rem; } button:hover { background-color: rgb(45, 125, 252); }

With that, we should be ready to run our app—but there's a pitfall to address first.

WebAssembly/Rust Tutorial: So Close!

Now when we run yarn and yarn start , switch to the browser, and attempt to record audio (using Chrome or Chromium, with developer tools open), we're met with some errors:

At wasm_audio.js line 24 there's the error, "Uncaught ReferenceError: TextDecoder is not defined," followed by one at setupAudio.js line 84 triggered by the async onClick from App.js line 43, which reads, "Uncaught (in promise) Error: Failed to load audio analyzer WASM module. Further info: Failed to construct 'AudioWorkletNode': AudioWorkletNode cannot be created: The node name 'PitchProcessor' is not defined in AudioWorkletGlobalScope."
Wasm requirements have wide support—just not yet in the Worklet spec.

The first error, TextDecoder is not defined , occurs when the browser attempts to execute the contents of wasm_audio.js . This in turn results in the failure to load the Wasm JavaScript wrapper, which produces the second error we see in the console.

The underlying cause of the issue is that modules produced by the Wasm package generator of Rust assume that TextDecoder (and TextEncoder ) will be provided by the browser. This assumption holds for modern browsers when the Wasm module is being run from the main thread or even a worker thread. However, for worklets (such as the AudioWorklet context needed in this tutorial), TextDecoder and TextEncoder are not yet part of the spec and so are not available.

TextDecoder is needed by the Rust Wasm code generator to convert from the flat, packed, shared-memory representation of Rust to the string format that JavaScript uses. Put another way, in order to see strings produced by the Wasm code generator, TextEncoder and TextDecoder must be defined.

This issue is a symptom of the relative newness of WebAssembly. As browser support improves to support common WebAssembly patterns out of the box, these issues will likely disappear.

For now, we are able to work around it by defining a polyfill for TextDecoder .

Create a new file public/TextEncoder.js and import it from public/PitchProcessor.js :

 import "./TextEncoder.js";

Make sure that this import statement comes before the wasm_audio import.

Finally, paste this implementation into TextEncoder.js (courtesy of @Yaffle on GitHub).

The Firefox Question

As mentioned earlier, the way we combine Wasm with Web Audio worklets in our app will not work in Firefox. Even with the above shim, clicking the “Start Listening” button will result in this:

 Unhandled Rejection (Error): Failed to load audio analyzer WASM module. Further info: Failed to load audio analyzer worklet at url: PitchProcessor.js. Further info: The operation was aborted.

That's because Firefox doesn't yet support importing modules from AudioWorklets —for us, that's PitchProcessor.js running in the AudioWorklet thread.

The Completed Application

Once done, we simply reload the page. The app should load without error. Click “Start Listening” and allow your browser to access your microphone. You'll see a very basic pitch detector written in JavaScript using Wasm:

A screenshot of the app showing its title,
Real-time pitch detection.

Programming in WebAssembly with Rust: A Real-time Web Audio Solution

In this tutorial, we have built a web application from scratch that performs computationally expensive audio processing using WebAssembly. WebAssembly allowed us to take advantage of near-native performance of Rust to perform the pitch detection. Further, this work could be performed on another thread, allowing the main JavaScript thread to focus on rendering to support silky-smooth frame rates even on mobile devices.

Wasm/Rust and Web Audio Takeaways

  • Modern browsers provide performant audio (and video) capture and processing inside web apps.
  • Rust has great tooling for Wasm, which helps recommend it as the language of choice for projects incorporating WebAssembly.
  • Compute-intensive work can be performed efficiently in the browser using Wasm.

Despite the many WebAssembly advantages, there are a couple Wasm pitfalls to watch out for:

  • Tooling for Wasm within worklets is still evolving. For example, we needed to implement our own versions of TextEncoder and TextDecoder functionality required for passing strings between JavaScript and Wasm because they were missing from the AudioWorklet context. That, and importing Javascript bindings for our Wasm support from an AudioWorklet is not yet available in Firefox.
  • Although the application we developed was very simple, building the WebAssembly module and loading it from the AudioWorklet required significant setup. Introducing Wasm to projects does introduce an increase in tooling complexity, which is important to keep in mind.

For your convenience, this GitHub repo contains the final, completed project. If you also do back-end development, you may also be interested in using Rust via WebAssembly within Node.js.


مزيد من القراءة على مدونة Toptal Engineering:

  • واجهة برمجة تطبيقات Web Audio: لماذا الإنشاء ومتى يمكنك البرمجة؟
  • WebVR الجزء 3: فتح إمكانات WebAssembly و AssemblyScript