Mở đầu: mọi thuật toán trước giờ đều "chưa có deadline"

13 bài trước xử lý tín hiệu như một MẢNG có sẵn — gọi hàm, chờ kết quả, xong. Audio thời gian thực KHÔNG cho phép điều đó: loa cần một mẫu mới đúng $1/f_s$ giây một lần, KHÔNG THƯƠNG LƯỢNG. Trễ deadline dù chỉ một lần cũng nghe ra ngay — tiếng "click" hoặc "dropout" khó chịu. Bài này chuyển góc nhìn từ "thuật toán đúng" sang "thuật toán đúng VÀ kịp giờ", rồi bước sang thế giới phần cứng nhúng nơi ngay cả phép NHÂN cũng có giá.


📚 Điều kiện tiên quyết
Bắt buộc: Bài 11 (biquad). Khuyến khích (không bắt buộc): loạt Series 13 — Lập trình Nhúng cho bối cảnh phần cứng thật.

1. Ngân sách thời gian thực

Audio xử lý theo block — một lô $N$ mẫu liên tiếp — vì gọi callback cho TỪNG mẫu đơn lẻ quá tốn overhead. Ngân sách thời gian của 1 block hoàn toàn cố định: $T_{block} = N / f_s$. Với $N=128$ mẫu, $f_s=48000$Hz: $T_{block} = 128/48000 = 2,667$ms — TOÀN BỘ xử lý của block đó (mọi filter, mọi phép tính) phải xong trong đúng 2,667ms, nếu không buffer phát ra "trống" và loa phát tiếng click.

Block size (mẫu @ 48kHz) Ngân sách/latency Đánh đổi
64 1,333ms Trễ thấp nhất — nhưng overhead callback (gọi hàm) trên mỗi mẫu CAO nhất
128 2,667ms Phổ biến cho ứng dụng tương tác thời gian thực (nhạc cụ số, gọi thoại)
512 10,667ms Hiệu quả CPU cao (ít callback hơn) — trễ đã bắt đầu cảm nhận được khi chơi nhạc cụ
1024 21,333ms Hiệu quả nhất, trễ RÕ RỆT — chỉ chấp nhận được cho phát lại (không tương tác)

Trễ TỔNG mà tai nghe được không chỉ là $T_{block}$ — còn cộng thêm trễ của CHÍNH filter (nhóm delay của FIR, Bài 9) và trễ phần cứng (DAC, driver hệ điều hành). Block to hơn = CPU hiệu quả hơn (ít lần gọi hàm/ngắt hơn) NHƯNG trễ tương tác lớn hơn — đây là trade-off cốt lõi của Mục này.

2. Sample-by-sample vs block processing: cạm bẫy trạng thái

Một biquad (Bài 11) có TRẠNG THÁI ($z_1, z_2$ của Direct Form II Transposed) — trạng thái đó phải SỐNG XUYÊN qua ranh giới giữa các block, vì filter đang xử lý một dòng tín hiệu LIÊN TỤC, không phải từng đoạn độc lập. biquadDF2T() của Bài 11 khởi tạo $z_1=z_2=0$ MỖI LẦN được gọi — dùng ĐÚNG cho 1 tín hiệu trọn vẹn, nhưng dùng SAI nếu gọi lại nó cho MỖI block riêng biệt của cùng một dòng audio.

block_state_pitfall.js (trích engine dsp-core.js)
// DUNG: state SONG xuyen block, object `state` duoc SUA TRUC TIEP moi lan goi
function biquadDF2TBlock(x, coeffs, state) {
  let z1 = state.z1, z2 = state.z2;
  const y = new Array(x.length);
  for (let n = 0; n < x.length; n++) {
    const yn = coeffs.b0 * x[n] + z1;
    z1 = coeffs.b1 * x[n] - coeffs.a1 * yn + z2;
    z2 = coeffs.b2 * x[n] - coeffs.a2 * yn;
    y[n] = yn;
  }
  state.z1 = z1; state.z2 = z2;  // luu lai cho block KE TIEP
  return y;
}
// Verified: xu ly theo block 128 mau bang ham NAY khop TUYET DOI (diff=0)
// voi xu ly nguyen khoi 1 lan. Dung SAI bieuadDF2T() (Bai 11, RESET state
// moi lan goi) cho tung block tao buoc nhay 0,49 tai RANH GIOI block - gap
// HON 25 LAN buoc nhay binh thuong giua 2 mau lien tiep (0,0196) = CLICK.
⚠️ Cạm bẫy kinh điển: reset state mỗi block → click chu kỳ đều đặn
Đây là lỗi HẦU NHƯ AI CŨNG DÍNH lần đầu viết audio processor theo block: gọi lại hàm filter "sạch" (khởi tạo state về 0) cho mỗi block tưởng là an toàn — nhưng thực chất đang CẮT ĐỨT dòng tín hiệu liên tục thành nhiều đoạn ĐỘC LẬP GIẢ, mỗi đoạn "quên" hoàn toàn lịch sử ngay trước nó. Verified: bước nhảy tại các ranh giới block ($0,49$) lớn hơn HƠN 25 LẦN bước nhảy tự nhiên giữa 2 mẫu bất kỳ trong xử lý đúng ($0,0196$) — đủ lớn để tai nghe ra thành tiếng click đều đặn theo chu kỳ block.

3. Fixed-point Q15: khi không có FPU

MCU không có FPU (Floating Point Unit) phải MÔ PHỎNG phép nhân số thực bằng phần mềm — chậm hơn phép nhân số nguyên gấp hàng chục lần. Giải pháp chuẩn công nghiệp: Q15 — biểu diễn số thực trong khoảng $[-1, 1)$ bằng số NGUYÊN 16-bit có dấu (int16), với "dấu phẩy tưởng tượng" ngay sau bit dấu: giá trị thực $= \text{int16}/32768$. Nhân 2 số Q15 cho ra kết quả Q30 (gấp đôi số bit thập phân) — phải dịch phải 15 bit ($(a \times b) \gg 15$) để đưa VỀ ĐÚNG Q15, và phải bão hoà (saturate) kết quả về đúng phạm vi int16 thay vì để tràn số âm thầm quấn vòng.

q15_sim.js (trích engine dsp-core.js)
function floatToQ15(x) {
  const clamped = Math.max(-1, Math.min(0.999969482421875, x)); // Q15 KHONG bieu dien duoc dung +1
  return Math.round(clamped * 32768);
}
function satQ15(x) { return Math.max(-32768, Math.min(32767, x)); }
function q15Mul(a, b) { return satQ15(Math.round((a * b) / 32768)); } // (a*b)>>15, co bao hoa
// Verified: floatToQ15(1,0) = 32767 (BAO HOA, khong the bieu dien dung +1)
// Verified: q15Mul(32767,32767) = 32766 (bao hoa DUNG, khong quan vong am)

Verified bằng self-test: firQ15() (FIR chạy hoàn toàn bằng số học Q15) so với FIR float gốc trên cùng tín hiệu đạt SNR hơn 84dB — nhiễu lượng tử hệ số/mẫu gần như không thể nghe ra được (tham chiếu: SQNR lý thuyết của Q15 toàn dải là khoảng 92dB, Bài 3 đã tính công thức này). Cái giá không miễn phí: hệ số lọc bị LÀM TRÒN về lưới Q15 (nhiễu lượng tử hệ số) — chính là hạt giống của pitfall pole-trượt đã gặp ở Bài 11.

⚠️ Vì sao biquad Q15 "thật" cần thêm 1 tham số postShift
Thử lượng tử trực tiếp hệ số $a_1$ của một biquad lowpass thật ($f_0=1000$Hz, $Q=0,707$, $f_s=48000$Hz) về Q15: giá trị thực là $a_1 \approx -1,815$ — VƯỢT phạm vi $[-1,1)$ mà Q15 biểu diễn được, bị cắt cụt thành $-1,0$, phá vỡ hoàn toàn bộ lọc. Đây chính là lý do hàm arm_biquad_cascade_df1_q15 của CMSIS-DSP (thư viện DSP chuẩn công nghiệp cho ARM Cortex-M) có thêm tham số postShift: hệ số được CHIA NHỎ trước khi lượng tử (để vừa khung Q15), rồi kết quả được NHÂN LẠI đúng hệ số đó sau khi tính xong — bài học: fixed-point IIR thật tinh vi hơn nhiều so với FIR, nằm ngoài phạm vi demo của bài (demo Mục 5 dùng FIR — nơi mọi tap tự nhiên đã nhỏ hơn 1, không gặp vấn đề này).

4. Thế giới thật: CMSIS-DSP & cross-over với VMCU

Thư viện CMSIS-DSP chính thức của ARM cung cấp sẵn arm_biquad_cascade_df1_q15, arm_fir_q15, v.v. — cùng cấu trúc toán học đã xây ở Mục 2-3, nhưng viết bằng assembly/C tối ưu cho tập lệnh SIMD của Cortex-M, kèm tham số postShift vừa nhắc ở Mục 3. Nếu đã học Series 13 — Lập trình Nhúng: VMCU (bộ mô phỏng vi điều khiển ảo của series đó) có đồng hồ cycle ảo — chạy đúng biquad này trên VMCU và đếm số cycle cho 1 mẫu sẽ thấy trực quan TẠI SAO Q15 tồn tại (nhân số nguyên tốn ít cycle hơn hẳn mô phỏng số thực bằng phần mềm trên MCU không FPU). Phần cross-over này KHÔNG bắt buộc để hiểu bài — chỉ là một trải nghiệm mở rộng cho ai đã đi qua series đó.

5. Thực hành: A/B Float vs Q15

Nghe và so trực tiếp CÙNG một filter chạy bằng số thực (float) và bằng mô phỏng Q15 — engine tính SNR thật ngay khi bấm nghe. Kéo block size để xem ngân sách thời gian đổi theo, và bật "click do reset state" để tự tai nghe ra đúng pitfall Mục 2:

⏱️ A/B Float vs Q15 & Block Latency — DSPJS thật
Ngân sách: 2,667ms/block
Đang khởi tạo…

"Nghe Float" và "Nghe Q15" phát cùng 1 tín hiệu qua cùng 1 filter — engine hiện SNR thật ngay bên dưới. "Nghe click" phát 1 tông liên tục xử lý SAI theo block 128 mẫu (state bị reset) — chú ý tiếng "tách" đều đặn.

realtime_ab_demo.js (đúng logic đang chạy ở tab Xem trước)
import { sine, firLowpassDesign, hannWindow, convolve, firQ15, biquadCoeffsRBJ, biquadDF2T, biquadDF2TBlock } from './dsp-core.js';

function computeSnrDb(fs) {
  const h = firLowpassDesign(41, 1000 / (fs / 2), hannWindow);
  const x = Array.from({ length: 4000 }, (_, n) => 0.5 * sine(n, 300, fs, 1, 0));
  const yFloat = convolve(x, h).slice(0, x.length);
  const yQ15 = firQ15(x, h);
  let sp = 0, np = 0;
  for (let i = 200; i < x.length; i++) {
    sp += yFloat[i] ** 2;
    np += (yFloat[i] - yQ15[i]) ** 2;
  }
  return 10 * Math.log10(sp / np);
}

function clickDemo(fs, blockSize) {
  const coeffs = biquadCoeffsRBJ('lowpass', 1000, fs, 0.707, 0);
  const x = Array.from({ length: 4000 }, (_, n) => 0.5 * sine(n, 300, fs, 1, 0));
  let yBuggy = [];
  for (let i = 0; i < x.length; i += blockSize) {
    yBuggy = yBuggy.concat(biquadDF2T(x.slice(i, i + blockSize), coeffs)); // SAI: state reset moi block
  }
  return yBuggy;
}

Tóm lược

  • ✅ Ngân sách thời gian thực cố định: block $N$ mẫu @ $f_s$ = $N/f_s$ giây, không thương lượng — verified 128 mẫu @ 48kHz = 2,667ms.
  • ✅ Trạng thái biquad phải sống XUYÊN ranh giới block — verified: biquadDF2TBlock() (giữ state) khớp tuyệt đối xử lý nguyên khối; reset state mỗi block tạo bước nhảy gấp hơn 25 lần bình thường tại ranh giới (click nghe được).
  • ✅ Q15 = int16 với dấu phẩy tưởng tượng, nhân là $(a \times b) \gg 15$ + bão hoà — verified FIR Q15 đạt SNR hơn 84dB so với float.
  • ✅ Biquad IIR Q15 "thật" cần thêm tham số postShift (như CMSIS arm_biquad_cascade_df1_q15) vì hệ số $a_1/a_2$ thường vượt phạm vi $[-1,1)$ — verified $a_1 \approx -1,815$ cho 1 lowpass thật.
  • ✅ Block to hơn = CPU hiệu quả hơn nhưng trễ tương tác lớn hơn — trade-off cốt lõi thiết kế realtime.

Trắc nghiệm ôn tập

Câu 1

Verified: block 128 mẫu @ 48kHz = 2,667ms. Con số này có ý nghĩa gì?

Câu 2

Verified: reset state biquad mỗi block tạo bước nhảy 0,49 tại ranh giới, gấp hơn 25 lần bước nhảy bình thường (0,0196). Vì sao lỗi này xảy ra?

Câu 3

Q15 dùng $(a \times b) \gg 15$ thay vì $a \times b$ thông thường khi nhân 2 số. Vì sao?

Câu 4

Verified: $a_1 \approx -1,815$ của 1 biquad lowpass thật vượt phạm vi Q15 $[-1,1)$. CMSIS-DSP giải quyết bằng tham số nào?

Tải file code thực hành minh họa bài học

File JavaScript DSPJS (dsp-core.js) — thư viện DSP tự viết dùng xuyên suốt cả 15 bài, Bài 14 vừa thêm floatToQ15(), q15ToFloat(), satQ15(), q15Mul(), q15Add(), firQ15(), biquadDF2TBlock(), kèm self-test đối chiếu đúng mọi hành vi trong bài (chạy node dsp-core.js, không cần cài thêm gì):

Tải về dsp-core.js

📖 Tài liệu tham khảo

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

Bài 13: Phát hiện cao độ: autocorrelation & tuner Bài 15: Capstone — Trạm Âm Thanh DSP hoàn chỉnh Quay lại Lộ trình Series Xử Lý Tín Hiệu Số

Bình luận