Mở đầu: đổi sample rate — dễ sai hơn tưởng
44,1kHz (CD) và 48kHz (video) là 2 tốc độ lấy mẫu phổ biến nhất, nhưng KHÔNG chia hết cho nhau — mọi hệ thống trộn 2 chuẩn này (video có nhạc CD, hay ASR giảm 96kHz xuống 16kHz cho model học máy) đều phải đổi sample rate. Cách nghĩ đầu tiên ai cũng thử: "muốn ít mẫu hơn thì bỏ bớt mẫu cách quãng" — verify ngay bằng số thật: cách này gây aliasing tức thì, đúng con quái vật đã gặp ở Bài 2 quay lại đòi nợ.
Bài này chỉ ra cách làm ĐÚNG — lọc trước rồi mới đổi rate — bằng cách tái dùng trực tiếp FIR windowed-sinc của Bài 9, tổng quát hoá thành tỉ lệ hữu tỉ L/M bất kỳ (44,1↔48kHz hoá ra chỉ là 160/147), và hé lộ một mẹo tối ưu tên polyphase — đừng bao giờ nhân với số bạn sắp vứt đi.
1. Bài toán đổi sample rate: cách "ngây thơ" gây aliasing
Muốn đổi từ $f_s$ xuống $f_s/M$? Cách nhanh nhất nhìn thấy là chỉ giữ lại 1 trong mỗi $M$ mẫu, vứt hết phần còn lại — chính là lấy mẫu lại tín hiệu ở tốc độ thấp hơn, và Nyquist (Bài 2) áp dụng y hệt: mọi tần số VƯỢT Nyquist MỚI ($f_s/(2M)$) sẽ gập ngược về một tần số khác, nghe SAI hoàn toàn.
// Cach NGAY THO: bo mau cach quang, KHONG loc truoc
function downsampleNaive(x, M) {
const y = [];
for (let i = 0; i < x.length; i += M) y.push(x[i]);
return y;
}
// Verified: tone 10kHz qua downsampleNaive(x, 4) tu fs=48000 (rate moi 12000,
// Nyquist moi 6000) - bien do RMS SAU khi bo mau van ~0,707 (KHONG suy giam)
// va gap thanh DUNG 2000Hz - khop CHINH XAC aliasFrequency(10000, 12000) cua Bai 2.
Con số verified: sau downsampleNaive(x, 4), tone 10.000Hz (vượt Nyquist mới 6.000Hz) vẫn giữ
nguyên biên độ RMS $\approx 0,707$ — hoàn toàn KHÔNG bị suy giảm, chỉ bị "gập" thành đúng
2.000Hz (khớp chính xác aliasFrequency(10000, 12000) của
Bài 2). Tai người nghe sẽ nghe một cao độ hoàn toàn SAI, không liên
quan gì tới âm gốc.
2. Decimation & Interpolation: lọc đúng chỗ, đúng lúc
Sửa lỗi trên chỉ cần ĐẢO thứ tự: lọc trước, bỏ mẫu sau. Gọi bước giảm rate là
decimation ↓M: chặn mọi tần số trên Nyquist mới bằng 1 low-pass windowed-sinc (tái dùng
TRỰC TIẾP firLowpassDesign() của Bài 9, cắt tại $f_s/(2M)$),
RỒI mới bỏ mẫu — nội dung đáng lẽ gây aliasing đã bị triệt tiêu trước khi có cơ hội gập phổ.
// Decimation DUNG cach: LOC TRUOC (chan Nyquist MOI) roi moi bo mau
function decimate(x, M, numTaps, windowFn) {
const h = firLowpassDesign(numTaps, 1 / (2 * M), windowFn); // cat tai fs/(2M)
const filtered = convolve(x, h);
return downsampleNaive(filtered, M);
}
// Verified: CUNG tone 10kHz qua decimate(x, 4, 81, hannWindow) - RMS sau loc
// giam xuong DUOI 0,05 (so voi 0,707 khi khong loc) - triet tieu THAT SU.
// Interpolation DUNG cach: chen 0 (len tam thoi) roi loc SACH anh pho
function interpolate(x, L, numTaps, windowFn) {
const zeroStuffed = upsampleZeroStuff(x, L);
const h = firLowpassDesign(numTaps, 1 / (2 * L), windowFn).map((v) => v * L);
return convolve(zeroStuffed, h);
}
// Verified: upsample L=2 mot tone 300Hz roi so voi hinh sin LY TUONG lien tuc
// tai rate moi (da bu tre nhom filter) - sai so toi da CHI 0,000017.
.map((v) => v * L) trong interpolate()), tín hiệu sau khi lọc sẽ nhỏ hơn gốc
đúng $L$ lần dù về hình dạng sóng vẫn ĐÚNG — một lỗi âm thầm rất dễ bỏ sót vì không gây lỗi cú pháp, chỉ
làm "nhạc nhỏ tiếng lại" một cách khó hiểu.
Ghép 2 khối lại giải quyết BẤT KỲ tỉ lệ hữu tỉ $L/M$: lên trước ($\times L$, chèn 0) → MỘT filter chung cắt tại Nyquist NHỎ HƠN trong 2 Nyquist (đảm bảo an toàn cả 2 chiều) → xuống sau ($\div M$, bỏ mẫu). Tỉ lệ hữu tỉ giữa 2 chuẩn phổ biến nhất, rút gọn bằng ước chung lớn nhất:
| Đổi rate | Tỉ lệ L/M tối giản | Ghi chú |
|---|---|---|
| 44,1kHz → 48kHz | $L/M = 160/147$ | $\gcd(48000, 44100) = 300$ — cần cả lên lẫn xuống |
| 48kHz → 44,1kHz | $L/M = 147/160$ | Chiều ngược lại, cùng cặp số |
| 96kHz → 16kHz | $L/M = 1/6$ | $\gcd(96000, 16000) = 16000$ — decimate SẠCH 6:1, không cần lên |
96kHz→16kHz là trường hợp đặc biệt gọn: chỉ cần decimate(x, 6, ...), không cần bước lên —
đúng pipeline tiền xử lý âm thanh phổ biến trước khi đưa vào model ASR/ML (nối
Series 12 AI).
3. Polyphase: đừng nhân với số 0
Nhìn kỹ interpolate() ở Mục 2: sau khi chèn 0, filter convolve() nhân MỌI hệ số
$h$ với MỌI mẫu — kể cả những số 0 vừa chèn (chiếm $(L-1)/L$ tổng số mẫu!). Ý tưởng
polyphase: tách filter $h$ thành $L$ "nhánh pha" nhỏ, mỗi nhánh chỉ nhân với đúng những
mẫu THẬT (khác 0) — bỏ qua hoàn toàn phần nhân-với-số-0 vô ích. Tương tự ở chiều decimate: phần lớn mẫu đã
lọc SẼ BỊ VỨT NGAY sau đó bởi downsampleNaive() — polyphase chỉ tính đúng những mẫu SẼ ĐƯỢC
GIỮ LẠI, không tính rồi vứt.
Con số verified bằng đếm trực tiếp (không cần chạy code, chỉ đếm phép nhân): với $L=160$ (chiều 44,1→48kHz) và filter $81$ tap, cách trực tiếp (chèn 0 rồi lọc nguyên khối) tốn $N_{in} \times 160 \times 81$ phép nhân cho $N_{in}$ mẫu vào; polyphase chỉ tốn $N_{in} \times 81$ — đúng ít hơn 160 lần, khớp chính xác hệ số $L$ (mọi phép nhân với số 0 hoặc mẫu sắp bị vứt đều bị loại bỏ, không thừa một phép tính nào).
4. Resampling quanh ta: ảnh cũng y hệt
Bài toán resampling không chỉ có ở âm thanh — thay đổi kích thước ảnh chính là resampling
2 chiều. Bộ lọc Lanczos (một lựa chọn chất lượng cao phổ biến khi resize ảnh) chính là
windowed-sinc — CÙNG công thức sinc() của Bài 9, chỉ áp theo 2
trục thay vì 1. Phóng to ảnh = interpolation (chèn pixel "0" rồi lọc mượt); thu nhỏ ảnh không lọc trước =
đúng loại aliasing ở Mục 1, biểu hiện bằng hoạ tiết răng cưa/moiré khi thu nhỏ ảnh có chi tiết mịn (lưới,
vải sọc) — thử ngay trên
Image Optimizer
hoặc xem sâu hơn ở series Canvas (xử lý pixel).
5. Thực hành: Resampler A/B
Chọn tần số tone và hệ số giảm rate $M$ — nghe và SO SÁNH trực tiếp đường "ngây thơ"
(downsampleNaive) với đường "chuẩn" (decimate có lọc), cùng phổ biên độ 2 bên vẽ
song song. Kéo tần số tone vượt qua Nyquist mới để nghe rõ tiếng "sai cao độ" của aliasing:
Phổ NGÂY THƠ (downsampleNaive — không lọc):
Phổ CHUẨN (decimate — lọc trước):
Kéo tone lên trên Nyquist mới (hiện trong ô trạng thái) — bản NGÂY THƠ đổi cao độ hẳn (aliasing), bản CHUẨN gần như im lặng (đã lọc sạch).
import { sine, downsampleNaive, decimate, hannWindow, aliasFrequency, fft, dftMagnitude, zeroPad } from './dsp-core.js';
function compareResample(freq, fs, M) {
const newRate = fs / M;
const x = Array.from({ length: 4000 }, (_, n) => sine(n, freq, fs, 1, 0));
const naive = downsampleNaive(x, M); // KHONG loc - co the alias
const proper = decimate(x, M, 81, hannWindow); // loc truoc dung cach
const naiveSpectrum = dftMagnitude(fft(zeroPad(naive, 2048))); // 2048 > 1000 mau sau /4
const properSpectrum = dftMagnitude(fft(zeroPad(proper, 2048)));
const predictedAlias = aliasFrequency(freq, newRate); // Bai 2 tai dung
return { newRate, naive, proper, naiveSpectrum, properSpectrum, predictedAlias };
}
Tóm lược
-
✅ Đổi rate "ngây thơ" (bỏ mẫu, không lọc) gây aliasing — verified: tone 10kHz qua
downsampleNaive(x,4)giữ NGUYÊN biên độ (RMS≈0,707), gập thành đúng 2000Hz (khớpaliasFrequency()của Bài 2). -
✅
decimate()/interpolate()lọc đúng chỗ đúng lúc — verified: cùng tone 10kHz quadecimate()bị triệt tiêu (RMS<0,05); upsample L=2 khớp sin lý tưởng sai số chỉ 0,000017. - ✅ Tỉ lệ hữu tỉ verified thật: 44,1kHz↔48kHz = 160/147 ($\gcd=300$), 96kHz→16kHz chỉ cần decimate sạch 6:1.
- ✅ Polyphase: đừng nhân với số sắp vứt/số 0 — verified bằng đếm trực tiếp: ít hơn ĐÚNG $L$ lần phép nhân so với cách trực tiếp.
- ✅ Resampling ảnh (Lanczos) = windowed-sinc 2D — cùng công thức
sinc()của Bài 9.
Trắc nghiệm ôn tập
Câu 1
Vì sao cách "ngây thơ" bỏ mẫu cách quãng (downsampleNaive) gây aliasing?
Câu 2
decimate() sửa vấn đề của downsampleNaive() bằng cách nào?
Câu 3
Verified: 44,1kHz↔48kHz rút gọn về tỉ lệ L/M = 160/147. Vì sao cần CẢ lên lẫn xuống (không chỉ 1 chiều)?
Câu 4
Ý tưởng cốt lõi của polyphase (Mục 3) là gì?
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 12 vừa thêm gcdInt(), upsampleZeroStuff(),
downsampleNaive(), decimate(), interpolate(),
resampleRational(), 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