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.


📚 Điều kiện tiên quyết
Bắt buộc: Bài 6 (FFT) và Bài 7 (hàm cửa sổ — mỗi khung STFT đều cần cửa sổ để chống leakage).

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 độ.

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

🚧 Đánh đổi hop size: mượt thời gian ↔ chi phí tính toán
Chồng lấp 75% (hopSize = frameSize/4) cho spectrogram mượt hơn hẳn về mặt thời gian so với không chồng lấp (hopSize = frameSize) — nhưng cần FFT gấp 4 LẦN số khung cho cùng một đoạn tín hiệu. Với xử lý thời gian thực (spectrogram từ mic sống), đây là chi phí CPU thật phải cân nhắc, không chỉ là con số lý thuyết.

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.

⚠️ Pitfall: quên đổi sang dB — "toàn đen" không phải demo hỏng
Nếu vẽ trực tiếp biên độ TUYẾN TÍNH (không qua 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È:

🌈 Spectrogram — DSPJS thật
Kích thước khung = 256
Đang khởi tạo…

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.

spectrogram_demo.js (đúng logic đang chạy ở tab Xem trước)
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ì):

Tải về dsp-core.js

📖 Tài liệu tham khảo

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

Bài 7: Rò rỉ phổ & hàm cửa sổ Bài 9: Filter FIR: từ trung bình trượt đến windowed-sinc Quay lại Lộ trình Series Xử Lý Tín Hiệu Số

Bình luận