Mẫu thiết kế worklet âm thanh

Hongchan Choi

Bài viết trước trên Audio Worklet đã trình bày chi tiết các khái niệm cơ bản và cách sử dụng. Kể từ khi Chrome 66 ra mắt, chúng tôi đã nhận được nhiều yêu cầu đưa ra thêm ví dụ về cách sử dụng Chrome trong các ứng dụng thực tế. Audio Worklet khai thác hết tiềm năng của WebAudio, nhưng việc tận dụng được công nghệ này có thể không hề dễ dàng vì công cụ này đòi hỏi người dùng phải hiểu việc lập trình đồng thời được gói bằng một số API JS. Ngay cả đối với các nhà phát triển đã quen thuộc với WebAudio, việc tích hợp Audio Worklet với các API khác (ví dụ: WebAssembly) cũng có thể gây khó khăn.

Bài viết này sẽ giúp người đọc hiểu rõ hơn về cách sử dụng Audio Worklet trong chế độ cài đặt thực tế, cũng như cung cấp các mẹo để tận dụng tối đa công suất của thiết bị. Đừng quên xem các đoạn mã ví dụ và bản minh hoạ trực tiếp nhé!

Tóm tắt: Worklet âm thanh

Trước khi tìm hiểu sâu hơn, hãy cùng tóm tắt nhanh các thuật ngữ và thông tin liên quan đến hệ thống Audio Worklet đã được giới thiệu trước đó trong bài đăng này.

  • BaseAudioContext: Đối tượng chính của Web Audio API.
  • Audio Worklet: Một trình tải tệp tập lệnh đặc biệt cho thao tác Audio Worklet. Thuộc về BaseAudioContext. Mỗi BaseAudioContext có thể có một Worklet âm thanh. Tệp tập lệnh đã tải sẽ được đánh giá trong AudioWorkletGlobalScope và dùng để tạo các thực thể AudioWorkletProcessor.
  • AudioWorkletGlobalScope: Một phạm vi toàn hệ thống JS đặc biệt cho hoạt động của Audio Worklet. Chạy trên một luồng kết xuất dành riêng cho WebAudio. Một BaseAudioContext có thể có một AudioWorkletGlobalScope.
  • AudioWorkletNode: Một AudioNode được thiết kế cho hoạt động của Audio Worklet. Tạo thực thể từ một BaseAudioContext. Một BaseAudioContext có thể có nhiều AudioWorkletNode tương tự như AudioNode gốc.
  • AudioWorkletProcessor: Bộ xử lý tương ứng của AudioWorkletNode. Bộ phận thực tế của AudioWorkletNode xử lý luồng âm thanh theo mã do người dùng cung cấp. Hệ thống sẽ tạo thực thể này trong AudioWorkletGlobalScope khi AudioWorkletNode được tạo. Mỗi AudioWorkletNode có thể có một AudioWorkletProcessor phù hợp.

Mẫu thiết kế

Sử dụng Worklet âm thanh với WebAssembly

WebAssembly là bạn đồng hành hoàn hảo cho AudioWorkletProcessor. Việc kết hợp 2 tính năng này mang lại nhiều lợi thế cho hoạt động xử lý âm thanh trên web, nhưng hai lợi ích lớn nhất là: a) đưa mã xử lý âm thanh C/C++ hiện có vào hệ sinh thái WebAudio và b) tránh chi phí cho hoạt động biên dịch JIT JS và việc thu gom rác trong mã xử lý âm thanh.

Dữ liệu đầu vào đóng vai trò quan trọng đối với các nhà phát triển đã sẵn sàng đầu tư vào mã và thư viện xử lý âm thanh. Tuy nhiên, tính năng này rất quan trọng đối với gần như tất cả người dùng API. Trong thế giới WebAudio, hạn mức thời gian cho luồng âm thanh ổn định là khá đòi hỏi: chỉ 3 mili giây ở tốc độ lấy mẫu 44,1Khz. Ngay cả một cú giật nhẹ trong mã xử lý âm thanh cũng có thể gây ra sự cố. Nhà phát triển phải tối ưu hoá mã để xử lý nhanh hơn, nhưng cũng giảm thiểu lượng rác JS được tạo. Sử dụng WebAssembly có thể là một giải pháp cùng lúc giải quyết cả hai vấn đề: giải pháp này nhanh hơn và không tạo rác từ mã.

Phần tiếp theo mô tả cách sử dụng WebAssembly với Audio Worklet và bạn có thể xem ví dụ về mã đi kèm tại đây. Để xem hướng dẫn cơ bản về cách sử dụng Emscripten và WebAssembly (đặc biệt là mã keo Emscripten), vui lòng xem bài viết này.

Thiết lập

Mọi thứ đều ổn, nhưng chúng ta cần một chút cấu trúc để thiết lập mọi thứ đúng cách. Câu hỏi đầu tiên về thiết kế cần đặt ra là cách thức và vị trí tạo thực thể cho một mô-đun WebAssembly. Sau khi tìm nạp mã kết nối của Emscripten, có 2 đường dẫn để tạo thực thể mô-đun:

  1. Tạo thực thể cho mô-đun WebAssembly bằng cách tải mã kết nối vào AudioWorkletGlobalScope thông qua audioContext.audioWorklet.addModule().
  2. Tạo thực thể cho mô-đun WebAssembly trong phạm vi chính, sau đó chuyển mô-đun qua các tuỳ chọn hàm khởi tạo của AudioWorkletNode.

Quyết định này chủ yếu phụ thuộc vào thiết kế và lựa chọn ưu tiên của bạn. Tuy nhiên, ý tưởng là mô-đun WebAssembly có thể tạo một thực thể WebAssembly trong AudioWorkletGlobalScope, trở thành hạt nhân xử lý âm thanh trong thực thể AudioWorkletProcessor.

Mẫu tạo thực thể mô-đun WebAssembly A: Sử dụng lệnh gọi .addModule()
Mẫu tạo thực thể mô-đun WebAssembly A: Sử dụng lệnh gọi .addModule()

Để mẫu A hoạt động chính xác, Emscripten cần một vài tuỳ chọn để tạo đúng mã kết dính WebAssembly cho cấu hình của chúng ta:

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

Các tuỳ chọn này đảm bảo việc biên dịch đồng bộ của mô-đun WebAssembly trong AudioWorkletGlobalScope. Thư viện này cũng thêm định nghĩa lớp của AudioWorkletProcessor vào mycode.js để có thể tải sau khi khởi động mô-đun. Lý do chính để sử dụng tính năng biên dịch đồng bộ là vì độ phân giải lời hứa của audioWorklet.addModule() không chờ sự phân giải lời hứa trong AudioWorkletGlobalScope. Thường thì bạn không nên tải hoặc biên dịch đồng bộ trong luồng chính vì phương pháp này chặn các tác vụ khác trong cùng một luồng, nhưng ở đây, chúng ta có thể bỏ qua quy tắc này vì quá trình biên dịch diễn ra trên AudioWorkletGlobalScope mà chạy ngoài luồng chính. (Xem mục này để biết thêm thông tin.)

Mẫu tạo thực thể mô-đun WASM B: Sử dụng tính năng chuyển nhiều luồng của hàm khởi tạo AudioWorkletNode
Mẫu tạo thực thể mô-đun WASM B: Sử dụng tính năng chuyển nhiều luồng của hàm khởi tạo AudioWorkletNode

Mẫu B có thể hữu ích nếu cần phải nâng vật nặng không đồng bộ. Mô-đun này sử dụng luồng chính để tìm nạp mã kết nối từ máy chủ và biên dịch mô-đun. Sau đó, mô-đun WASM sẽ được chuyển qua hàm khởi tạo của AudioWorkletNode. Mẫu này càng có ý nghĩa hơn khi bạn phải tải mô-đun một cách linh động sau khi AudioWorkletGlobalScope bắt đầu kết xuất luồng âm thanh. Tuỳ thuộc vào kích thước của mô-đun, việc biên dịch mô-đun giữa quá trình kết xuất có thể gây ra các sự cố trong luồng.

Dữ liệu âm thanh và vùng nhớ khối xếp WASM

Mã WebAssembly chỉ hoạt động trên bộ nhớ được phân bổ trong một vùng nhớ khối xếp WASM chuyên biệt. Để tận dụng cơ chế này, dữ liệu âm thanh cần được sao chép qua lại giữa vùng nhớ khối xếp WASM và các mảng dữ liệu âm thanh. Lớp HeapAudioBuffer trong mã ví dụ xử lý thao tác này rất tuyệt vời.

Lớp HeapAudioBuffer để sử dụng vùng nhớ khối xếp WASM dễ dàng hơn
Dùng lớp HeapAudioBuffer để dễ sử dụng vùng nhớ khối xếp WASM

Có một đề xuất sớm đang được thảo luận để tích hợp trực tiếp vùng nhớ khối xếp WASM vào hệ thống Audio Worklet. Việc loại bỏ hoạt động sao chép dữ liệu dư thừa này giữa bộ nhớ JS và vùng nhớ khối xếp WASM có vẻ tự nhiên, nhưng bạn cần phải xử lý các chi tiết cụ thể.

Xử lý dung lượng bộ nhớ đệm không khớp

Cặp AudioWorkletNode và AudioWorkletProcessor được thiết kế để hoạt động như một AudioWork thông thường; AudioWorkletNode xử lý hoạt động tương tác với các mã khác, còn AudioWorkletProcessor xử lý âm thanh nội bộ. Vì một AudioNode thông thường xử lý 128 khung hình cùng lúc nên AudioWorkletProcessor cũng phải làm tương tự để trở thành một tính năng cốt lõi. Đây là một trong những ưu điểm của thiết kế Audio Worklet là đảm bảo không có thêm độ trễ do tính năng lưu vào bộ đệm nội bộ được đưa vào AudioWorkletProcessor, nhưng có thể gây ra vấn đề nếu một chức năng xử lý yêu cầu dung lượng bộ nhớ đệm khác với 128 khung hình. Giải pháp phổ biến cho trường hợp như vậy là sử dụng vùng đệm vòng, còn gọi là vùng đệm tròn hoặc FIFO.

Dưới đây là sơ đồ của AudioWorkletProcessor sử dụng hai bộ đệm vòng bên trong để hỗ trợ hàm WASM lấy 512 khung hình vào và ra. (Số 512 ở đây được chọn tuỳ ý.)

Sử dụng RingBuffer bên trong phương thức "process()" của AudioWorkletProcessor
Sử dụng RingBuffer bên trong phương thức "process()" của AudioWorkletProcessor

Thuật toán cho biểu đồ sẽ là:

  1. AudioWorkletProcessor đẩy 128 khung hình vào Input RingBuffer từ Đầu vào.
  2. Chỉ thực hiện các bước sau nếu Input RingBuffer có nhiều hơn hoặc bằng 512 khung hình.
    1. Lấy 512 khung hình từ Input RingBuffer.
    2. Xử lý 512 khung bằng hàm WASM đã cho.
    3. Đẩy 512 khung vào Output RingBuffer.
  3. AudioWorkletProcessor lấy 128 khung hình từ Output RingBuffer để lấp đầy Output (Đầu ra).

Như minh hoạ trong sơ đồ, Khung đầu vào luôn được tích luỹ vào InputRingBuffer và xử lý tình trạng tràn vùng đệm bằng cách ghi đè khối khung cũ nhất trong vùng đệm. Đó là một điều hợp lý cần làm đối với ứng dụng âm thanh theo thời gian thực. Tương tự, khối Khung đầu ra sẽ luôn được hệ thống kéo. Vùng đệm (không đủ dữ liệu) trong Output RingBuffer sẽ dẫn đến tình trạng im lặng gây ra sự cố trong luồng.

Mẫu này rất hữu ích khi thay thế ScriptProcessorNode (SPN) bằng AudioWorkletNode. Vì SPN cho phép nhà phát triển chọn kích thước bộ đệm trong khoảng từ 256 đến 16384 khung hình, vì vậy, việc thay thế SPN bằng AudioWorkletNode có thể khó khăn và việc sử dụng bộ đệm vòng sẽ là một giải pháp hay. Máy ghi âm là một ví dụ tuyệt vời có thể được xây dựng trên thiết kế này.

Tuy nhiên, bạn cần hiểu rằng thiết kế này chỉ khắc phục vấn đề dung lượng bộ nhớ đệm không khớp và không giúp bạn có thêm thời gian để chạy mã tập lệnh nhất định. Nếu mã không thể hoàn tất tác vụ trong phạm vi ngân sách thời gian của lượng tử hiển thị (~3 mili giây ở 44,1Khz), điều này sẽ ảnh hưởng đến thời gian bắt đầu của hàm gọi lại tiếp theo và cuối cùng gây ra sự cố.

Việc kết hợp thiết kế này với WebAssembly có thể phức tạp do việc quản lý bộ nhớ xung quanh vùng nhớ khối xếp WASM. Tại thời điểm viết, dữ liệu vào và ra khỏi vùng nhớ khối xếp WASM phải được sao chép nhưng chúng ta có thể sử dụng lớp HeapAudioBuffer để việc quản lý bộ nhớ trở nên dễ dàng hơn một chút. Ý tưởng sử dụng bộ nhớ do người dùng phân bổ để giảm tình trạng sao chép dữ liệu dư thừa sẽ được thảo luận trong tương lai.

Bạn có thể tìm thấy lớp RingBuffer tại đây.

WebAudio Powerhouse: Audio Worklet và SharedArrayBuffer

Mẫu thiết kế cuối cùng trong bài viết này là đưa một số API tiên tiến vào cùng một nơi; Audio Worklet, SharedArrayBuffer, AtomicsWorker. Với chế độ thiết lập không hề nhỏ này, tính năng này mở ra một đường dẫn để phần mềm âm thanh hiện có được viết bằng C/C++ có thể chạy trên trình duyệt web trong khi vẫn duy trì trải nghiệm mượt mà cho người dùng.

Tổng quan về mẫu thiết kế gần đây nhất: Audio Worklet, SharedArrayBuffer và Worker
Tổng quan về mẫu thiết kế gần đây nhất: Audio Worklet, SharedArrayBuffer và Worker

Ưu điểm lớn nhất của thiết kế này là bạn có thể chỉ sử dụng DedicatedWorkerGlobalScope để xử lý âm thanh. Trong Chrome, WorkerGlobalScope chạy trên luồng có mức độ ưu tiên thấp hơn luồng kết xuất WebAudio nhưng có một số ưu điểm hơn AudioWorkletGlobalScope. DedicatedWorkerGlobalScope ít bị hạn chế hơn về nền tảng API có sẵn trong phạm vi. Ngoài ra, bạn có thể nhận được sự hỗ trợ tốt hơn từ Emscripten vì Worker API đã tồn tại được một vài năm.

SharedArrayBuffer đóng vai trò quan trọng để thiết kế này hoạt động hiệu quả. Mặc dù cả Worker và AudioWorkletProcessor đều được trang bị tính năng thông báo không đồng bộ (MessagePort), nhưng hoạt động này chưa đạt hiệu quả tối ưu đối với việc xử lý âm thanh theo thời gian thực do độ trễ của thông báo và hoạt động phân bổ bộ nhớ lặp lại. Vì vậy, chúng tôi phân bổ trước một khối bộ nhớ có thể truy cập được từ cả hai luồng để chuyển dữ liệu hai chiều nhanh chóng.

Theo quan điểm của nhà tinh lọc Web Audio API, thiết kế này có thể chưa tối ưu vì nó sử dụng Audio Worklet như một "bồn lưu âm thanh" đơn giản và thực hiện mọi việc trong Worker. Tuy nhiên, khi xét đến chi phí cho việc viết lại dự án C/C++ trong JavaScript có thể quá cao hoặc thậm chí là không thể, thiết kế này có thể là cách triển khai hiệu quả nhất cho những dự án như vậy.

Trạng thái và nguyên tử được chia sẻ

Khi sử dụng bộ nhớ dùng chung cho dữ liệu âm thanh, việc truy cập từ cả hai bên phải được phối hợp cẩn thận. Chia sẻ trạng thái có thể truy cập nguyên tử là một giải pháp cho vấn đề như vậy. Chúng ta có thể tận dụng Int32Array được SAB hỗ trợ cho mục đích này.

Cơ chế đồng bộ hoá: SharedArrayBuffer và Atomics
Cơ chế đồng bộ hoá: SharedArrayBuffer và Atomics

Cơ chế đồng bộ hoá: SharedArrayBuffer và Atomics

Mỗi trường của mảng Trạng thái biểu thị thông tin quan trọng về các vùng đệm dùng chung. Điều quan trọng nhất là một trường để đồng bộ hoá (REQUEST_RENDER). Ý tưởng là Worker sẽ chờ AudioWorkletProcessor chạm vào trường này và xử lý âm thanh khi đánh thức. Cùng với SharedArrayBuffer (SAB), Atomics API giúp cho cơ chế này trở nên khả thi.

Lưu ý rằng việc đồng bộ hoá 2 luồng khá lỏng lẻo. Sự khởi đầu của Worker.process() sẽ được kích hoạt bằng phương thức AudioWorkletProcessor.process(), nhưng AudioWorkletProcessor sẽ không đợi cho đến khi Worker.process() hoàn tất. Đây là nguyên tắc thiết kế; AudioWorkletProcessor điều khiển bằng lệnh gọi lại âm thanh nên không được chặn đồng bộ. Trong trường hợp xấu nhất, luồng âm thanh có thể bị trùng lặp hoặc bị loại bỏ, nhưng cuối cùng sẽ được khôi phục khi hiệu suất kết xuất được ổn định.

Thiết lập và đang chạy

Như minh hoạ trong sơ đồ ở trên, thiết kế này có một số thành phần cần sắp xếp: DedicatedWorkerGlobalScope (DWGS), AudioWorkletGlobalScope (AWGS), SharedArrayBuffer và luồng chính. Các bước sau đây mô tả những gì sẽ xảy ra trong giai đoạn khởi chạy.

Khởi chạy
  1. [Chính] Hàm khởi tạo AudioWorkletNode được gọi.
    1. Tạo Worker.
    2. AudioWorkletProcessor được liên kết sẽ được tạo.
  2. [DWGS] Worker tạo 2 SharedArrayBuffers. (một cho trạng thái chia sẻ và một cho dữ liệu âm thanh)
  3. [DWGS] Worker gửi tệp tham chiếu SharedArrayBuffer đến AudioWorkletNode.
  4. [Chính] AudioWorkletNode gửi tệp tham chiếu SharedArrayBuffer đến AudioWorkletProcessor.
  5. [AWGS] AudioWorkletProcessor thông báo cho AudioWorkletNode rằng quy trình thiết lập đã hoàn tất.

Sau khi khởi chạy xong, AudioWorkletProcessor.process() sẽ bắt đầu được gọi. Sau đây là những điều sẽ xảy ra trong mỗi lần lặp lại của vòng lặp kết xuất.

Vòng lặp kết xuất
Hiển thị đa luồng bằng SharedArrayBuffers
Hiển thị đa luồng bằng SharedArrayBuffers
  1. [AWGS] AudioWorkletProcessor.process(inputs, outputs) được gọi cho mọi lượng tử kết xuất.
    1. inputs sẽ được đẩy vào Input SAB.
    2. outputs sẽ được điền bằng cách sử dụng dữ liệu âm thanh trong SAB đầu ra.
    3. Cập nhật Trạng thái SAB với các chỉ mục vùng đệm mới cho phù hợp.
    4. Nếu Output SAB gần đạt đến ngưỡng luồng dưới, Wake Worker sẽ kết xuất nhiều dữ liệu âm thanh hơn.
  2. [DWGS] Worker chờ (ngủ) tín hiệu thức từ AudioWorkletProcessor.process(). Khi thiết bị báo thức:
    1. Tìm nạp các chỉ mục vùng đệm từ States SAB (Trạng thái).
    2. Chạy hàm xử lý với dữ liệu từ Input SAB để điền Output SAB.
    3. Cập nhật Trạng thái SAB với các chỉ mục vùng đệm cho phù hợp.
    4. Chuyển sang chế độ ngủ và chờ tín hiệu tiếp theo.

Bạn có thể tìm thấy mã ví dụ tại đây, nhưng lưu ý rằng bạn phải bật cờ thử nghiệm SharedArrayBuffer để bản minh hoạ này hoạt động. Mã này được viết bằng mã JS thuần tuý để đơn giản hoá, nhưng có thể thay thế bằng mã WebAssembly nếu cần. Bạn nên xử lý trường hợp như vậy cẩn thận hơn bằng cách gói quản lý bộ nhớ bằng lớp HeapAudioBuffer.

Kết luận

Mục tiêu cuối cùng của Audio Worklet là làm cho Web Audio API thực sự "có thể mở rộng". Chúng tôi đã nỗ lực thiết kế trong nhiều năm để có thể triển khai phần còn lại của API Web Audio với Audio Worklet. Đổi lại, hiện chúng tôi có thiết kế phức tạp hơn và đây có thể là một thách thức không mong muốn.

May mắn là nguyên nhân dẫn đến sự phức tạp như vậy chỉ là do việc hỗ trợ các nhà phát triển. Việc có thể chạy WebAssembly trên AudioWorkletGlobalScope mở ra tiềm năng rất lớn cho việc xử lý âm thanh hiệu suất cao trên web. Đối với các ứng dụng âm thanh quy mô lớn được viết bằng C hoặc C++, việc sử dụng Audio Worklet với SharedArrayBuffers và Worker có thể là một lựa chọn hấp dẫn để bạn khám phá.

Ghi công

Xin đặc biệt cảm ơn Chris Wilson, Jason Miller, Joshua Bell và Raymond Toy vì đã xem xét bản nháp của bài viết này và đưa ra ý kiến phản hồi chi tiết.