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):

bitcrusher-processor.js
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);
🔬 Đào sâu: Vì sao process() phải cực nhanh?
Mỗi khối 128 mẫu chỉ có ~2.9ms (ở 44.1kHz) trước khi luồng audio cần khối tiếp theo. Nếu 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:

main-thread.js
await audioContext.audioWorklet.addModule('bitcrusher-processor.js');
const node = new AudioWorkletNode(audioContext, 'bitcrusher-processor');
source.connect(node).connect(audioContext.destination);
🕳️ Cạm bẫy thường gặp: Tạo node trước khi 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 (qua static 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 trong process() qua parameters.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ý qua this.port.onmessage bê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 N mẫ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.

🔧 Sân chơi tương tác: Bitcrusher Live

Bitcrusher (AudioWorklet thật)

Nhật ký

audio-worklet-dsp-live.js

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?

📖 Tài liệu tham khảo / References

Bài viết liên quan trong series

Bài 8: Dự Án — Music Visualizer Bài 6: Spatial & Stereo Audio Quay lại Lộ trình Series Web Audio API