Bài viết trước về Audio Worklet đã trình bày chi tiết các khái niệm và cách sử dụng cơ bản. Kể từ khi ra mắt trong Chrome 66, đã có nhiều yêu cầu về các ví dụ khác về cách sử dụng tính năng này trong các ứng dụng thực tế. Audio Worklet khai thác toàn bộ tiềm năng của WebAudio, nhưng việc tận dụng tính năng này có thể gặp khó khăn vì bạn cần phải hiểu rõ về lập trình đồng thời được gói bằng một số API JS. Ngay cả đối với những 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ặp 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 Worklet âm thanh trong các chế độ cài đặt thực tế và đưa ra các mẹo để khai thác tối đa sức mạnh của Worklet này. Đừng quên xem các ví dụ về mã và bản minh hoạ trực tiếp!
Tóm tắt: Audio Worklet
Trước khi đi sâu vào nội dung, hãy cùng tóm tắt nhanh các thuật ngữ và thông tin thực tế về 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 API Âm thanh trên web.
- Audio Worklet (Tác vụ âm thanh): 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. BaseAudioContext có thể có một Audio Worklet. Tệp tập lệnh đã tải được đánh giá trong AudioWorkletGlobalScope và được dùng để tạo các thực thể AudioWorkletProcessor.
- AudioWorkletGlobalScope: Phạm vi toàn cục JS đặc biệt cho hoạt động Audio Worklet. Chạy trên một luồng kết xuất chuyên dụng cho WebAudio. BaseAudioContext có thể có một AudioWorkletGlobalScope.
- AudioWorkletNode: Một AudioNode được thiết kế cho hoạt động Audio Worklet. Được tạo bản sao từ BaseAudioContext. BaseAudioContext có thể có nhiều AudioWorkletNodes tương tự như AudioNodes gốc.
- AudioWorkletProcessor: Một đối tác của AudioWorkletNode. Phần cốt lõi thực tế của AudioWorkletNode xử lý luồng âm thanh bằng mã do người dùng cung cấp. Lớp này được tạo bản sao trong AudioWorkletGlobalScope khi tạo một AudioWorkletNode. Một AudioWorkletNode có thể có một AudioWorkletProcessor khớp.
Mẫu thiết kế
Sử dụng Audio Worklet với WebAssembly
WebAssembly là một công cụ đồng hành hoàn hảo cho AudioWorkletProcessor. Việc kết hợp hai tính năng này mang lại nhiều lợi thế cho việc 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 hao tổn khi biên dịch JIT JS và thu gom rác trong mã xử lý âm thanh.
API đầu tiên rất quan trọng đối với các nhà phát triển đã đầu tư vào mã và thư viện xử lý âm thanh, nhưng API thứ hai lại rất quan trọng đối với gần như tất cả người dùng API. Trong thế giới WebAudio, ngân sách thời gian cho luồng âm thanh ổn định khá khắt khe: chỉ 3 mili giây ở tốc độ lấy mẫu 44,1 Khz. Ngay cả một sự cố 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 phải giảm thiểu lượng rác JS được tạo. Việc sử dụng WebAssembly có thể là giải pháp giải quyết cả hai vấn đề cùng một lúc: nhanh hơn và không tạo ra 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. Để biết 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
Nghe có vẻ tuyệt vời, 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 thiết kế đầu tiên cần đặt ra là làm thế nào và nơi tạo bản sao của mô-đun WebAssembly. Sau khi tìm nạp mã keo của Emscripten, có hai đường dẫn để tạo bản sao mô-đun:
- Tạo bản sao mô-đun WebAssembly bằng cách tải mã keo vào
AudioWorkletGlobalScope thông qua
audioContext.audioWorklet.addModule()
. - Tạo bản sao mô-đun WebAssembly trong phạm vi chính, sau đó chuyển mô-đun đó thông qua các tuỳ chọn hàm khởi tạo của AudioWorkletNode.
Quyết định này phụ thuộc phần lớn vào thiết kế và lựa chọn ưu tiên của bạn, nhưng ý 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 một thực thể AudioWorkletProcessor.
Để mẫu A hoạt động chính xác, Emscripten cần một vài tuỳ chọn để tạo mã keo WebAssembly chính xác 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 quá trình biên dịch đồng bộ của mô-đun WebAssembly trong AudioWorkletGlobalScope. Tệp 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 chạy mô-đun.
Lý do chính để sử dụng tính năng biên dịch đồng bộ là độ phân giải lời hứa của audioWorklet.addModule()
không chờ độ phân giải của lời hứa trong AudioWorkletGlobalScope. Bạn không nên tải hoặc biên dịch đồng bộ trong luồng chính vì việc này sẽ 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, chạy trên luồng chính. (Xem nội dung này để biết thêm thông tin.)
Mẫu B có thể hữu ích nếu bạn cần thực hiện các tác vụ nặng không đồng bộ. Thư viện này sử dụng luồng chính để tìm nạp mã keo từ máy chủ và biên dịch mô-đun. Sau đó, lớp này sẽ chuyển mô-đun WASM thông 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 sự cố trong luồng.
Vùng nhớ khối xếp WASM và dữ liệu âm thanh
Mã WebAssembly chỉ hoạt động trên bộ nhớ được phân bổ trong vùng nhớ khối xếp WASM chuyên dụng. Để tận dụng tính năng này, dữ liệu âm thanh cần được nhân bản 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 một cách hiệu quả.
Chúng tôi đang thảo luận về một đề xuất ban đầu để tích hợp vùng nhớ khối xếp WASM trực tiếp vào hệ thống Audio Worklet. Việc loại bỏ hoạt động nhân bản dữ liệu thừa này giữa bộ nhớ JS và vùng nhớ khối xếp WASM có vẻ như là điều tự nhiên, nhưng cần phải giải quyết các chi tiết cụ thể.
Xử lý trường hợp không khớp kích thước bộ đệm
Cặp AudioWorkletNode và AudioWorkletProcessor được thiết kế để hoạt động như một AudioNode thông thường; AudioWorkletNode xử lý hoạt động tương tác với các mã khác trong khi AudioWorkletProcessor xử lý hoạt động xử lý âm thanh nội bộ. Vì một AudioNode thông thường xử lý 128 khung cùng một lúc, nên AudioWorkletProcessor phải làm như vậy để 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, đảm bảo không có độ trễ bổ sung do vùng đệm nội bộ được đưa vào trong AudioWorkletProcessor, nhưng có thể là vấn đề nếu một hàm xử lý yêu cầu kích thước vùng đệm khác với 128 khung. Giải pháp phổ biến cho trường hợp như vậy là sử dụng bộ đệm vòng, còn gọi là bộ đệm tròn hoặc FIFO.
Dưới đây là sơ đồ của AudioWorkletProcessor sử dụng hai vùng đệm vòng bên trong để chứa một hàm WASM có 512 khung hình vào và ra. (Số 512 ở đây được chọn tuỳ ý.)
Thuật toán cho biểu đồ sẽ là:
- AudioWorkletProcessor đẩy 128 khung vào Input RingBuffer (Vòng đệm đầu vào) từ Đầu vào.
- Chỉ thực hiện các bước sau nếu Input RingBuffer có số khung hình lớn hơn hoặc bằng 512.
- Kéo 512 khung hình từ Input RingBuffer.
- Xử lý 512 khung hình bằng hàm WASM đã cho.
- Đẩy 512 khung vào Output RingBuffer (Vùng đệm vòng lặp đầu ra).
- AudioWorkletProcessor lấy 128 khung từ Output RingBuffer để điền vào Output.
Như minh hoạ trong sơ đồ, Khung đầu vào luôn được tích luỹ vào Input RingBuffer và xử lý tình trạng tràn bộ đệm bằng cách ghi đè khối khung hình cũ nhất trong bộ đệm. Đó là điều hợp lý để làm cho một ứ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. Tình trạng thiếu dữ liệu trong vùng đệm (không đủ dữ liệu) trong Output RingBuffer sẽ dẫn đến sự cố âm thanh gây ra sự cố trong luồng.
Mẫu này 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 vùng đệm từ 256 đến 16384 khung, nên việc thay thế SPN bằng AudioWorkletNode có thể gặp khó khăn và việc sử dụng vùng đệm vòng cung cấp một giải pháp hiệu quả. Trình ghi âm sẽ là một ví dụ tuyệt vời có thể được xây dựng dựa trên thiết kế này.
Tuy nhiên, điều quan trọng là bạn phải hiểu rằng thiết kế này chỉ điều chỉnh sự không khớp về kích thước vùng đệm và không cho thêm thời gian để chạy mã tập lệnh đã cho. 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 kết xuất (~3 mili giây ở 44,1 Khz), thì đ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 nhân bản, nhưng chúng ta có thể sử dụng lớp HeapAudioBuffer để giúp việc quản lý bộ nhớ 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 việc nhân bản dữ liệu 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, Atomics và Worker. Với chế độ thiết lập không hề đơn giản này, bạn có thể mở khoá một đường dẫn cho phần mềm âm thanh hiện có được viết bằng C/C++ để chạy trong trình duyệt web trong khi vẫn duy trì trải nghiệm người dùng mượt mà.
Ưu điểm lớn nhất của thiết kế này là có thể sử dụng DedicatedWorkerGlobalScope chỉ để 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 so với AudioWorkletGlobalScope. DedicatedWorkerGlobalScope ít bị ràng buộc hơn về giao diện API có trong phạm vi. Ngoài ra, bạn có thể mong đợi được Emscripten hỗ trợ tốt hơn vì Worker API đã tồn tại được 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 gửi thông báo không đồng bộ (MessagePort), nhưng tính năng này không tối ưu cho việc xử lý âm thanh theo thời gian thực do độ trễ trong việc phân bổ bộ nhớ và gửi thông báo lặp lại. Vì vậy, chúng ta phân bổ trước một khối bộ nhớ có thể truy cập được từ cả hai luồng để truyền dữ liệu hai chiều nhanh chóng.
Theo quan điểm của những người theo chủ nghĩa thuần tuý về API Âm thanh trên web, thiết kế này có vẻ không tối ưu vì sử dụng Audio Worklet làm "audio sink" (vùng chứa âm thanh) đơn giản và thực hiện mọi việc trong Worker. Tuy nhiên, xét đến chi phí viết lại các dự án C/C++ bằng JavaScript có thể rất tốn kém hoặc thậm chí là không thể, thiết kế này có thể là phương pháp triển khai hiệu quả nhất cho các dự án như vậy.
Trạng thái và nguyên tử dùng chung
Khi sử dụng bộ nhớ dùng chung cho dữ liệu âm thanh, bạn phải phối hợp cẩn thận quyền truy cập từ cả hai bên. Chia sẻ các trạng thái có thể truy cập nguyên tử là giải pháp cho vấn đề nà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
Mỗi trường của mảng Trạng thái đại diện cho thông tin quan trọng về vùng đệm dùng chung. Quan trọng nhất là trường để đồng bộ hoá (REQUEST_RENDER
). Ý tưởng là Worker chờ trường này được AudioWorkletProcessor chạm vào và xử lý âm thanh khi nó thức dậy. Cùng với SharedArrayBuffer (SAB), Atomics API giúp cơ chế này có thể thực hiện được.
Lưu ý rằng việc đồng bộ hoá hai luồng khá lỏng lẻo. Phương thức AudioWorkletProcessor.process()
sẽ kích hoạt sự bắt đầu của Worker.process()
, nhưng AudioWorkletProcessor không đợi đến khi Worker.process()
kết thúc. Đây là thiết kế; AudioWorkletProcessor được đ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ị gián đoạn, nhưng cuối cùng sẽ khôi phục khi hiệu suất kết xuất ổn định.
Thiết lập và chạy
Như minh hoạ trong sơ đồ trên, thiết kế này có một số thành phầ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
- [Main] Hàm khởi tạo AudioWorkletNode được gọi.
- Tạo Worker.
- AudioWorkletProcessor liên kết sẽ được tạo.
- [DWGS] Worker tạo 2 SharedArrayBuffer. (một cho trạng thái dùng chung và một cho dữ liệu âm thanh)
- [DWGS] Worker gửi tệp tham chiếu SharedArrayBuffer đến AudioWorkletNode.
- [Chính] AudioWorkletNode gửi tệp tham chiếu SharedArrayBuffer đến AudioWorkletProcessor.
- [AWGS] AudioWorkletProcessor thông báo cho AudioWorkletNode rằng quá trình thiết lập đã hoàn tất.
Sau khi quá trình khởi chạy hoàn tất, AudioWorkletProcessor.process()
sẽ bắt đầu được gọi. Sau đây là những gì sẽ xảy ra trong mỗi lần lặp của vòng lặp kết xuất.
Vòng lặp kết xuất
- [AWGS]
AudioWorkletProcessor.process(inputs, outputs)
được gọi cho mọi lượng kết xuất.inputs
sẽ được đẩy vào Input SAB (SAB đầu vào).outputs
sẽ được điền bằng cách sử dụng dữ liệu âm thanh trong Output SAB (SAB đầu ra).- Cập nhật States SAB bằng các chỉ mục vùng đệm mới cho phù hợp.
- Nếu Output SAB (SAB đầu ra) sắp đạt đến ngưỡng thiếu dữ liệu, hãy đánh thức Worker để hiển thị thêm dữ liệu âm thanh.
- [DWGS] Worker chờ (ngủ) tín hiệu đánh thức từ
AudioWorkletProcessor.process()
. Khi thiết bị thức dậy:- Tìm nạp chỉ mục vùng đệm từ States SAB.
- Chạy hàm xử lý bằng dữ liệu từ Input SAB (SAB đầu vào) để điền vào Output SAB (SAB đầu ra).
- Cập nhật States SAB bằng các chỉ mục vùng đệm tương ứng.
- Chuyển sang trạng thái ngủ và chờ tín hiệu tiếp theo.
Bạn có thể xem mã mẫu 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, nhưng bạn 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 một cách cẩn thận hơn bằng cách gói hoạt động 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à giúp Web Audio API thực sự "mở rộng". Chúng tôi đã nỗ lực trong nhiều năm để thiết kế API này nhằm có thể triển khai phần còn lại của API Âm thanh trên web bằng Audio Worklet. Do đó, giờ đây, chúng ta 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à lý do khiến việc này phức tạp như vậy chỉ là để trao quyền cho nhà phát triển. Khả năng chạy WebAssembly trên AudioWorkletGlobalScope mở ra tiềm năng to 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++, bạn có thể khám phá lựa chọn hấp dẫn là sử dụng Audio Worklet với SharedArrayBuffers và Worker.
Ghi công
Cảm ơn đặc biệt Chris Wilson, Jason Miller, Joshua Bell và Raymond Toy đã xem xét bản nháp của bài viết này và đưa ra ý kiến phản hồi sâu sắc.