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á.
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.
// 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.
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.
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.
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:
"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.
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ì):
Bình luận