6 bài trước đều dùng node có sẵn của Web Audio API. Bài này đi xa hơn: tự viết thuật toán xử lý từng mẫu âm (sample) một, chạy thật trên luồng audio thời gian thực của trình duyệt — không phải mô phỏng, không phải demo giả lập.
1. Vì Sao Cần Một Luồng Audio Riêng?
Cách cũ để xử lý mẫu âm tuỳ biến — ScriptProcessorNode (nay đã
deprecated) — chạy callback xử lý ngay trên main thread, cùng luồng
với việc render UI, chạy animation, xử lý sự kiện chuột... Nếu main thread bận dù chỉ vài chục
mili-giây (garbage collection, layout nặng), âm thanh bị giật/click nghe rất khó chịu.
AudioWorklet giải quyết đúng vấn đề này: code xử lý mẫu chạy trên
1 luồng riêng dành cho audio rendering, có độ ưu tiên cao và tách biệt hoàn toàn khỏi
mọi thứ đang xảy ra ở main thread.
2. Viết AudioWorkletProcessor
Một processor tuỳ biến là 1 class kế thừa AudioWorkletProcessor, đặt trong
1 file JS riêng (chạy trên luồng audio, không truy cập được DOM). Phương thức
process() được gọi tự động, mỗi lần xử lý đúng 1 khối 128 mẫu (~2.9 mili-giây ở 44.1kHz):
class BitcrusherProcessor extends AudioWorkletProcessor {
process(inputs, outputs, parameters) {
const input = inputs[0];
const output = outputs[0];
for (let ch = 0; ch < output.length; ch++) {
for (let i = 0; i < output[ch].length; i++) {
output[ch][i] = input[ch][i]; // pass-through đơn giản nhất
}
}
return true; // false sẽ HUỶ processor ngay lập tức
}
}
registerProcessor('bitcrusher-processor', BitcrusherProcessor);
process() phải cực nhanh?process() tính toán lâu hơn khoảng thời gian đó, sẽ không kịp cấp dữ liệu đúng hạn —
gây ra tiếng "click/pop" hoặc khoảng lặng đột ngột (audio dropout/underrun), y hệt hiện tượng giật
hình khi render 1 khung hình mất quá lâu. Đây là lý do code trong process()
tuyệt đối không được có thao tác chậm (fetch, DOM, cấp phát bộ nhớ lớn) — chỉ toán học thuần trên
mảng số đã cấp phát sẵn.
3. Nạp Module & Tạo AudioWorkletNode
Trước khi dùng được processor, phải nạp file chứa nó (bất đồng bộ) rồi mới tạo node tương ứng:
await audioContext.audioWorklet.addModule('bitcrusher-processor.js');
const node = new AudioWorkletNode(audioContext, 'bitcrusher-processor');
source.connect(node).connect(audioContext.destination);
addModule xong
addModule() trả về 1 Promise — nó tải file, thực thi, và đăng ký tên
processor một cách bất đồng bộ. Nếu gọi
new AudioWorkletNode(ctx, 'tên') trước khi await xong
addModule(), Web Audio API chưa hề biết processor tên đó tồn tại — ném lỗi ngay lập
tức. Luôn await đầy đủ trước khi tạo node.
4. Truyền Tham Số Qua Port: 2 Cách Khác Nhau
Có 2 cách hoàn toàn khác nhau để đưa dữ liệu vào processor đang chạy trên luồng riêng:
-
AudioParam(quastatic parameterDescriptors) — dùng cho giá trị liên tục, tự động nội suy mượt theo thời gian giống mọi AudioParam khác (Bài 1). Đọc trongprocess()quaparameters.tenTso. -
port.postMessage()— kênh nhắn tin 2 chiều (giống Web Worker) cho dữ liệu rời rạc, bất kỳ lúc nào: lệnh kích hoạt 1 lần, cấu hình phức tạp, hay dữ liệu không phải số đơn thuần. Xử lý quathis.port.onmessagebên trong processor.
5. Xây Bitcrusher: Giảm Bit Depth + Giảm Sample Rate
Bitcrusher tạo hiệu ứng âm thanh "lo-fi 8-bit" qua 2 kỹ thuật kết hợp:
- Giảm độ sâu bit (bit depth reduction): lượng tử hoá mỗi mẫu về 1 trong số $2^{\text{bits}}$ mức rời rạc, với bước lượng tử $step = 2^{-\text{bits}}$: $$x_{\text{quantized}} = step \times \text{round}\left(\dfrac{x}{step}\right)$$ càng ít bit, sóng càng "bậc thang" thô ráp.
-
Giảm tần số lấy mẫu hiệu dụng (sample-and-hold): chỉ lấy mẫu mới mỗi
Nmẫu gốc, giữ nguyên giá trị cũ cho các mẫu ở giữa — mô phỏng phát ở tần số lấy mẫu thấp hơn nhiều mà không cần resample thật.
6. So Sánh ScriptProcessorNode vs AudioWorkletNode
| Tiêu chí | ScriptProcessorNode (deprecated) | AudioWorkletNode |
|---|---|---|
| Chạy trên luồng nào? | Main thread | Luồng audio riêng, ưu tiên cao |
| Ảnh hưởng bởi UI/GC bận? | Có — dễ giật/click | Không |
| Truy cập DOM trong xử lý? | Có (cùng thread) | Không — phải qua port |
| Trạng thái chuẩn hiện tại | Deprecated, chỉ còn tương thích ngược | Khuyến nghị dùng cho mọi DSP tuỳ biến |
Sân chơi tương tác: Bitcrusher Live
Phát 1 âm sawtooth liên tục qua AudioWorkletNode thật, kéo slider bit depth/giảm mẫu để
nghe và nhìn dạng sóng biến đổi, hoặc bấm "Gây nhiễu" để gửi 1 thông điệp rời rạc qua
port.postMessage kích hoạt burst nhiễu trắng.
Bitcrusher (AudioWorklet thật)
Nhật ký
Trắc nghiệm ôn tập
Câu 1: Vì sao AudioWorkletNode thay thế ScriptProcessorNode (đã
deprecated)?
Trắc nghiệm ôn tập
Câu 2: Vì sao phải await audioContext.audioWorklet.addModule(...) xong TRƯỚC khi tạo
new AudioWorkletNode(...)?
Trắc nghiệm ôn tập
Câu 3: Khi nào nên dùng AudioParam thay vì port.postMessage() để truyền dữ
liệu vào processor?