Mở đầu: phổ của một bản nhạc thay đổi theo thời gian
Đưa nguyên một bản nhạc 3 phút vào dft()/fft() đã verify — kết quả là MỘT phổ
tần số duy nhất, trộn lẫn TẤT CẢ các nốt từ giây đầu tới giây cuối thành một đống. Biết được bài hát có
nốt Do và nốt Sol không giúp ích gì nếu không biết nốt nào vang lúc nào — DFT của cả bài hoàn toàn mù về
THỜI GIAN.
Giải pháp tự nhiên: đừng FFT cả bài cùng lúc — cắt thành nhiều khung NGẮN, FFT riêng từng khung, rồi xếp các cột phổ đó cạnh nhau theo thời gian. Đó chính là STFT (Short-Time Fourier Transform), và bức ảnh nhiệt vẽ nên từ nó gọi là spectrogram — công cụ để "nhìn thấy" âm thanh thay đổi theo thời gian.
1. STFT: cắt lát thời gian, FFT từng lát
Thay vì 1 FFT khổng lồ cho toàn bộ tín hiệu, STFT lặp lại đúng 3 bước cho từng khung: cắt một đoạn
frameSize mẫu, áp cửa sổ (Bài 7 — chống leakage ở MỖI khung), FFT khung đó ra 1 cột phổ.
Khung tiếp theo bắt đầu cách khung trước hopSize mẫu (Mục 3 giải thích vì sao cần CHỒNG LẤP).
Kết quả là một MẢNG các cột phổ — trục ngang là thời gian, trục dọc là tần số, độ sáng mỗi điểm là biên
độ.
function stft(x, windowFn, frameSize, hopSize) {
const win = windowFn(frameSize);
const numFrames = Math.floor((x.length - frameSize) / hopSize) + 1;
const frames = [];
for (let i = 0; i < numFrames; i++) {
const start = i * hopSize;
const frame = x.slice(start, start + frameSize);
frames.push(fft(applyWindow(frame, win))); // dung fft() DA VERIFY tu Bai 6
}
return frames;
}
Verify bằng self-test (node dsp-core.js): với frameSize=64,
hopSize=32 trên tín hiệu 256 mẫu, số khung đúng công thức
floor((256-64)/32)+1 = 7 khung — và từng khung khớp CHÍNH XÁC với việc tự tay cắt lát rồi gọi
fft(applyWindow(...)) — stft() không hề "bịa" logic riêng khác với các hàm đã
verify ở 2 bài trước.
2. Nguyên lý bất định thời gian-tần số
Khung CÀNG DÀI, độ phân giải tần số (Bài 5: $\Delta f = f_s/N$) càng mịn — nhưng bên trong 1 khung dài, STFT hoàn toàn "mù": không biết tần số đổi ở đâu bên trong khung đó, chỉ biết trung bình. Khung CÀNG NGẮN, biết chính xác THỜI ĐIỂM hơn — nhưng $\Delta f$ càng thô, khó phân biệt các tần số gần nhau. Đánh đổi này có dạng bất đẳng thức $\Delta t \cdot \Delta f \geq \text{const}$ — cùng HỌ toán học với nguyên lý bất định Heisenberg trong vật lý lượng tử (không phải trùng hợp ngẫu nhiên — cả hai đều là hệ quả của cùng một tính chất toán học của biến đổi Fourier). Không có "khung hoàn hảo" — chỉ có khung PHÙ HỢP với bài toán đang giải.
3. Khung chồng lấp & hop size
Cửa sổ Hann/Hamming (Bài 7) giảm dần về 0 ở 2 đầu khung — nghĩa là tín hiệu NẰM Ở MÉP khung gần như bị "xoá" khỏi phép đo, có nguy cơ bỏ sót sự kiện xảy ra đúng lúc đó. Giải pháp: cho các khung CHỒNG LẤP lên nhau (hopSize < frameSize, thường chồng 50-75%) — phần bị cửa sổ khung này "xoá" ở mép sẽ nằm giữa khung kế tiếp, nơi cửa sổ không suy giảm mạnh. Hop càng nhỏ (chồng lấp càng nhiều), trục thời gian càng mượt — nhưng trả giá bằng số khung phải FFT nhiều hơn (tốn tính toán hơn).
4. Đọc spectrogram như đọc chữ
Một khi đã quen mắt, spectrogram đọc được như một loại "chữ viết": vệt NGANG liên tục ở một tần số cố định là âm THANH (tonal) — giọng nói/nhạc cụ giữ nguyên cao độ; vệt DỌC ngắn trải rộng nhiều tần số là âm ĐỘNG (percussive) — tiếng gõ, tiếng "t"/"p" trong lời nói. Giọng nói người có các vệt ngang song song đặc trưng gọi là formant — hình dạng chúng phân biệt các nguyên âm a/i/u với nhau.
magnitudeToDb()), spectrogram gần như TOÀN
ĐEN — vì hầu hết năng lượng thật của âm thanh/nhạc tập trung ở vài đỉnh cực nhọn, còn lại chênh lệch
hàng trăm/nghìn lần khiến mắt người không phân biệt nổi trên thang tuyến tính. Đây KHÔNG phải demo bị
lỗi — phải chuyển sang thang dB (logarit) mới thấy được chi tiết ở cả vùng năng lượng
thấp lẫn cao cùng lúc. Quy tắc chung của mọi simulator trong series: mọi trạng thái "trông như lỗi" phải
có giải thích ngay trên UI, không để người đọc tưởng demo hỏng.
5. Thực hành: kéo kích thước khung, thấy đánh đổi sống
Demo dưới đây dựng sẵn một tín hiệu gồm 3 tông nối tiếp (400Hz → 800Hz → 1600Hz, mỗi tông ngắn) và chạy STFT thật. Kéo kích thước khung để thấy trực tiếp đánh đổi Mục 2: khung NHỎ cho ranh giới giữa 3 tông SẮC NÉT nhưng vệt tần số DÀY/mờ; khung LỚN cho vệt tần số MẢNH/rõ nhưng ranh giới thời gian NHOÈ:
Tín hiệu: 400Hz → 800Hz → 1600Hz, mỗi tông ~340ms. Kéo thanh trượt qua các mức 64/128/256/512/1024 để thấy: khung nhỏ (64) → ranh giới 3 tông sắc nét, vệt tần số dày; khung lớn (1024) → vệt tần số mảnh, ranh giới thời gian nhoè hẳn.
import { hannWindow, stft, stftMagnitudeDb, dbToColor } from './dsp-core.js';
const frameSize = 256; // 64 | 128 | 256 | 512 | 1024 - keo thanh truot de doi
const hopSize = frameSize / 4; // chong lap 75% (Muc 3)
const frames = stft(signal, hannWindow, frameSize, hopSize);
const spectrogramDb = stftMagnitudeDb(frames, -80); // -80dB san (Muc 4)
// Ve tung cot: chi can nua duoi pho (doi xung lien hop, Bai 5)
spectrogramDb.forEach((column, t) => {
for (let f = 0; f < frameSize / 2; f++) {
const [r, g, b] = dbToColor(column[f], -80, 0);
// ve pixel (t, f) mau rgb(r,g,b) len canvas
}
});
Tóm lược
- ✅ STFT: cắt khung ngắn, áp cửa sổ, FFT từng khung, xếp cột theo thời gian — spectrogram là ảnh nhiệt của $|STFT|$.
-
✅ Verified:
stft()khớp CHÍNH XÁC với tự tay cắt lát +fft(applyWindow(...))— cả khung đầu lẫn khung chồng lấp giữa chừng. - ✅ Nguyên lý bất định thời gian-tần số: khung dài mịn tần số mù thời gian, khung ngắn ngược lại — cùng họ với Heisenberg, không có khung hoàn hảo.
- ✅ Khung chồng lấp (hopSize < frameSize) bù lại phần cửa sổ "xoá" ở mép — trả giá bằng nhiều khung FFT hơn.
- ✅ Đọc spectrogram: vệt ngang = tonal, vệt dọc = percussive, formant = dấu vân tay nguyên âm.
- ✅ Pitfall: quên đổi dB khiến spectrogram "toàn đen" — không phải demo hỏng, chỉ thiếu 1 bước xử lý.
Trắc nghiệm ôn tập
Câu 1
Vì sao chạy DFT/FFT trên nguyên một bản nhạc dài không cho biết được nốt nào vang lúc nào?
Câu 2
Nguyên lý bất định thời gian-tần số trong STFT nói lên điều gì?
Câu 3
Vì sao các khung STFT cần CHỒNG LẤP lên nhau (hopSize < frameSize) thay vì đặt liền kề nhau?
Câu 4
Một spectrogram vẽ trực tiếp bằng biên độ tuyến tính (không qua dB) trông "toàn đen". Đây là dấu hiệu của điều 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 8 vừa thêm stft() + chuyển đổi dB + colormap, 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