Đâ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:

signal-flow.txt
[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.

scheduler.js
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
}
🔬 Đào sâu: Vì sao không phát nốt ngay khi tới lượt?
Nếu gọi 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).

frequency-ring.js
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ẹ.

beat-detector.js
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;
}
🕳️ Cạm bẫy thường gặp: Quên "cooldown" giữa 2 lần bắt nhịp
Khi 1 tiếng kick vang lên, năng lượng bass thường vượt ngưỡng trong nhiều khung hình liên tiếp (không chỉ đúng 1 frame) — nếu không giới hạn khoảng cách tối thiểu giữa 2 lần bắt nhịp (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:

particles.js
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ả.

🎧 Sân chơi tương tác: Music Visualizer Studio

Music Visualizer (trống máy thật)

Nhật ký

audio-music-visualizer-live.js

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?

📖 Tài liệu tham khảo / References

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

🎉 Hoàn thành Series — Quay lại Lộ trình Bài 7: AudioWorklet & DSP Tuỳ Biến Quay lại Lộ trình Series Web Audio API