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.


📚 Điều kiện tiên quyết
Bắt buộc: Bài 2 (aliasing, gập phổ) và Bài 9 (FIR windowed-sinc).

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.

naive_vs_proper.js (trích engine dsp-core.js)
// 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ổ.

decimate_interpolate.js (trích engine dsp-core.js)
// 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.
⚠️ Cạm bẫy: quên bù hệ số $L$ khi chèn 0
Chèn $L-1$ số 0 giữa mỗi mẫu làm NĂNG LƯỢNG TRUNG BÌNH của tín hiệu giảm đi đúng $L$ lần (đa số mẫu giờ bằng 0!) — nếu quên nhân bù hệ số $L$ vào hệ số filter (dòng .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.
💡 numTaps càng nhiều, dải chuyển tiếp càng gọn — nhưng trễ càng lớn
Y hệt trade-off đã gặp ở Bài 9: filter chống alias dùng CÀNG NHIỀU tap thì dải chuyển tiếp quanh Nyquist mới càng hẹp (chặn dứt khoát hơn, ít rò rỉ hơn), nhưng trễ nhóm $(numTaps-1)/2$ mẫu cũng CÀNG LỚN. Demo Mục 5 dùng 81 tap — đủ chặn rõ ràng ở tỉ lệ $\div 4$ nhưng vẫn đủ nhẹ để tính realtime trong trình duyệt.

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

⚠️ Phạm vi bài: chỉ trình bày mức sơ đồ + trực giác
Polyphase là một kỹ thuật CÀI ĐẶT (implementation trick) — kết quả TOÁN HỌC giống hệt cách trực tiếp ở Mục 2, chỉ khác về hiệu năng. Bài chỉ dừng ở mức hiểu Ý TƯỞNG (đếm phép nhân tiết kiệm được) và KHÔNG viết code polyphase đầy đủ — nằm ngoài phạm vi loạt bài này, dành cho ai muốn tối ưu implementation thực chiến (CMSIS-DSP, libsamplerate).

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:

🎛️ Resampler A/B — DSPJS thật

Phổ NGÂY THƠ (downsampleNaive — không lọc):

Phổ CHUẨN (decimate — lọc trước):

Đang khởi tạo…

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

resampler_ab_demo.js (đúng logic đang chạy ở tab Xem trước)
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ớp aliasFrequency() của Bài 2).
  • decimate()/interpolate() lọc đúng chỗ đúng lúc — verified: cùng tone 10kHz qua decimate() 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ì):

Tải về dsp-core.js

📖 Tài liệu tham khảo

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

Bài 11: Filter IIR & Biquad Bài 13: Phát hiện cao độ: autocorrelation & tuner Quay lại Lộ trình Series Xử Lý Tín Hiệu Số

Bình luận