Đây là dự án tổng kết series Web Audio API: ghép mọi mảnh kiến thức 7 bài trước thành một music visualizer thật — bộ trống máy tổng hợp âm thanh thời gian thực, phân tích phổ tần số bằng FFT, phát hiện nhịp bằng thuật toán năng lượng, và vẽ hệ hạt (particle) phản ứng theo từng nhịp trên Canvas. Không có audio giả lập, không có animation lập trình sẵn theo timeline — mọi chuyển động trên màn hình đều tính trực tiếp từ dữ liệu âm thanh thật đang phát.
1. Kiến Trúc Tổng Thể & Ôn Lại 7 Bài Trước
Dự án này không dùng API nào mới — nó là bài luyện tổng hợp, ghép lại các khối đã học qua từng bài trong series:
| Bài | Kiến thức tái sử dụng ở đây |
|---|---|
| Bài 1 | AudioContext, node graph, autoplay policy (khởi tạo sau user gesture). |
| Bài 2 |
OscillatorNode tổng hợp kick/bass/arpeggio, bao bì (envelope) qua
GainNode.
|
| Bài 3 | BiquadFilterNode lọc highpass cho hi-hat, lowpass cho bass. |
| Bài 4 |
AnalyserNode, getByteFrequencyData — xương sống của toàn bộ
visualizer.
|
| Bài 5 | Đo năng lượng tín hiệu (RMS-style) — áp dụng lại cho năng lượng dải bass khi phát hiện nhịp. |
| Bài 6 |
Vòng lặp requestAnimationFrame đọc dữ liệu audio mỗi khung hình để vẽ lại Canvas.
|
| Bài 7 | Tư duy "mỗi khối xử lý phải cực nhanh" — áp dụng cho vòng lặp vẽ particle (phải chạy được ở 60fps). |
Kiến trúc tín hiệu của toàn bộ demo:
[Kick/Bass/Hat/Arp oscillators] ──▶ masterGain ──▶ AnalyserNode ──▶ destination
│
▼
getByteFrequencyData() mỗi khung hình
│
┌───────────────┼────────────────┐
▼ ▼
Vẽ vòng tần số → màu Phát hiện nhịp → spawn particle
2. Nguồn Âm: Trống Máy Tổng Hợp Bằng Look-Ahead Scheduling
Thay vì dùng file nhạc có sẵn, demo tự tổng hợp một đoạn nhạc điện tử đơn giản (kick, hi-hat, bass,
arpeggio) bằng chính OscillatorNode đã học ở Bài 2 — visualizer phản ứng với âm thanh
thật do trình duyệt tạo ra, không phải file có sẵn. Lịch phát nốt dùng kỹ thuật
look-ahead scheduling: thay vì phát ngay lập tức từng nốt bằng
setTimeout (không chính xác vì luồng JS có thể bị trễ), một hàm chạy định kỳ mỗi 25ms sẽ
lên lịch trước mọi nốt rơi trong 100ms tới bằng thời gian chính xác của
audioContext.currentTime — độ chính xác nhịp điệu do chính audio clock đảm nhiệm, không
phụ thuộc độ trễ của JS timer.
const STEP_DURATION = 60 / 120 / 4; // nốt 16, ở 120 BPM
const LOOKAHEAD = 0.1; // giây — lên lịch trước 100ms
function scheduler() {
while (nextStepTime < audioContext.currentTime + LOOKAHEAD) {
scheduleStep(currentStep, nextStepTime); // gọi playKick/playHat/playBass/playArp
nextStepTime += STEP_DURATION;
currentStep++;
}
setTimeout(scheduler, 25); // kiểm tra lại sau 25ms
}
oscillator.start() đúng lúc setTimeout bắn callback, thời điểm
thực tế sẽ trễ vài đến vài chục mili-giây (do main thread bận việc khác) — tai người rất nhạy với độ
lệch nhịp dù chỉ vài chục ms. Cách đúng: mỗi lần callback chạy, không phát ngay
mà chỉ tính toán "nốt tiếp theo nên phát vào thời điểm nào" rồi gọi
oscillator.start(thời_điểm_chính_xác) — trình duyệt tự lo phát đúng lúc bằng audio
clock có độ chính xác cao hơn nhiều so với JS timer. Đây gọi là kỹ thuật
"A Tale of Two Clocks" (Chris Wilson, Web Audio API).
3. Trích Xuất Dải Tần & Vẽ Vòng Tần Số → Màu Sắc
Mỗi khung hình, getByteFrequencyData() trả về mảng biên độ theo tần số (đã học ở
Bài 4). Demo nhóm các bin liền kề thành 64 "thanh" xếp theo vòng
tròn, mỗi thanh có màu xác định theo vị trí tần số của nó — tần số thấp (bass) ánh xạ sang màu đỏ/cam
ấm, tần số cao (treble) ánh xạ sang tím/hồng lạnh:
$$\text{hue}(i) = \dfrac{i}{N_{\text{bars}}} \times 300$$
với $i$ là chỉ số thanh (0 đến $N_{\text{bars}}-1$), nhân với 300 thay vì 360 độ để tránh vòng màu quay lại đỏ ở đầu dải treble (giữ cảm giác "thấp = ấm, cao = lạnh" nhất quán).
const bars = 64;
const binsPerBar = Math.floor(freqData.length / bars);
for (let i = 0; i < bars; i++) {
let sum = 0;
for (let j = 0; j < binsPerBar; j++) sum += freqData[i * binsPerBar + j];
const amplitude = sum / binsPerBar / 255; // chuẩn hoá 0..1
const hue = (i / bars) * 300;
ctx.strokeStyle = `hsl(${hue}, 90%, 60%)`;
// vẽ 1 đoạn thẳng hướng ra ngoài theo `amplitude`...
}
4. Phát Hiện Nhịp: Năng Lượng Tức Thời vs Trung Bình Động
Đây là thuật toán phát hiện nhịp (beat detection) đơn giản nhất còn hoạt động tốt: so sánh năng lượng dải bass ngay tại khung hình hiện tại với trung bình động của chính nó trong khoảng nửa giây gần nhất. Khi năng lượng hiện tại vọt cao hơn hẳn trung bình gần đây — đó chính là lúc tiếng kick/bass vừa "đánh" xuống, tức 1 nhịp.
$$E_{\text{instant}} = \dfrac{1}{8}\sum_{i=0}^{7} \dfrac{f_i}{255}$$ $$E_{\text{avg}} = \dfrac{1}{M}\sum_{j=1}^{M} E_j$$ $$\text{beat} \iff E_{\text{instant}} > C \times E_{\text{avg}}$$
trong đó $f_i$ là 8 bin tần số thấp nhất (xấp xỉ dải bass), $M$ là số khung hình lưu lại (~43 khung, tương đương khoảng nửa giây ở 60fps), $C$ là hệ số nhạy (slider "độ nhạy" trong sân chơi bên dưới, mặc định 1.3) — $C$ càng lớn, càng khó kích hoạt nhịp giả (false positive) nhưng cũng dễ bỏ sót nhịp nhẹ.
function detectBeat(bassEnergy, now) {
bassHistory.push(bassEnergy);
if (bassHistory.length > 43) bassHistory.shift(); // ~0.7s ở 60fps
const avg = bassHistory.reduce((a, b) => a + b, 0) / bassHistory.length;
const isBeat = bassEnergy > avg * sensitivity && bassEnergy > 0.15 && now - lastBeatTime > 0.2;
if (isBeat) lastBeatTime = now;
return isBeat;
}
now - lastBeatTime > 0.2), thuật toán sẽ bắn ra
hàng chục "nhịp giả" liên tục chỉ từ 1 tiếng kick duy nhất, làm particle nổ ra dồn dập sai với nhạc.
5. Hệ Particle: Sinh — Cập Nhật — Biến Mất
Mỗi khi phát hiện nhịp, demo sinh ra một chùm particle bay toả ra từ tâm với hướng và tốc độ ngẫu
nhiên, mỗi particle tự giảm dần độ sống (life) mỗi khung hình cho tới khi biến mất — mô
hình particle system cổ điển:
function spawnBurst(x, y, color, count) {
for (let i = 0; i < count; i++) {
const angle = Math.random() * Math.PI * 2;
const speed = 1 + Math.random() * 3;
particles.push({ x, y, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, life: 1, color });
}
}
function updateParticles() {
particles.forEach((p) => {
p.x += p.vx;
p.y += p.vy;
p.vx *= 0.98; // ma sát — chậm dần
p.vy *= 0.98;
p.life -= 0.02; // mờ dần rồi biến mất
});
particles = particles.filter((p) => p.life > 0);
}
6. So Sánh: Beat Detection Đơn Giản vs Nâng Cao
| Tiêu chí | Năng lượng tức thời vs trung bình (bài này) | Onset detection nâng cao (spectral flux, ML) |
|---|---|---|
| Độ phức tạp cài đặt | Thấp — vài dòng code, chạy real-time dễ dàng | Cao — cần phân tích phổ chi tiết hoặc model đã huấn luyện |
| Độ chính xác | Tốt với nhạc có kick/bass rõ ràng (EDM, pop, hip-hop) | Tốt với mọi thể loại kể cả nhạc không có beat rõ (nhạc cổ điển, jazz) |
| Chi phí tính toán mỗi khung hình | Rất thấp — vài phép cộng/chia trên 8 số | Cao hơn — FFT nhiều băng tần, cửa sổ trượt phức tạp hơn |
| Phù hợp cho | Visualizer, game rhythm đơn giản, hiệu ứng ánh sáng theo nhạc | Phần mềm DJ chuyên nghiệp, phân tích nhạc học thuật |
Sân chơi tương tác: Music Visualizer Studio
Bấm "Phát nhạc demo" để nghe trống máy tổng hợp thật đang chạy, quan sát vòng tần số đổi màu theo từng dải âm và particle nổ ra đúng lúc nhịp trống rơi xuống. Kéo "độ nhạy" để chỉnh ngưỡng phát hiện nhịp — kéo cao quá sẽ bỏ sót nhịp, kéo thấp quá sẽ bắt cả nhịp giả.
Music Visualizer (trống máy thật)
Nhật ký
Trắc nghiệm ôn tập
Câu 1: Vì sao lịch phát nốt trống máy dùng "look-ahead scheduling" (lên lịch trước 100ms) thay vì
phát nốt ngay khi setTimeout bắn callback?
Trắc nghiệm ôn tập
Câu 2: Trong thuật toán phát hiện nhịp của bài này, biến "cooldown" (now - lastBeatTime > 0.2) dùng để làm gì?
Trắc nghiệm ôn tập
Câu 3: Nếu tăng "độ nhạy" ($C$) trong công thức beat ⟺ E_instant > C × E_avg lên rất
cao (ví dụ 2.5), điều gì có khả năng xảy ra?