This programming guide is only available in Vietnamese. Switch to Vietnamese to read the full article about the Web Audio API, procedural sound effects, audio visualization, and the art of "game juice".

Một game hay một ứng dụng đồ họa thực sự "sống động" không chỉ nhờ những đường nét đẹp, mà nhờ phản hồi giác quan: tiếng "blip" khi nhấn nút, màn hình rung nhẹ khi va chạm, hàng loạt mảnh vỡ bắn tung ra, và những chuyển động co giãn (squash & stretch) mượt mà. Người ta gọi tổng thể những thứ "gia vị" này là game juice. Bài học này sẽ đi từ nền tảng Web Audio API — cách tạo âm thanh hoàn toàn bằng code không cần file — đến các kỹ thuật juice thị giác như screen shake và particle burst, kết hợp với canvas để biến một bản dựng khô khan thành thứ khiến người dùng "đã tay".

1. Web Audio API cơ bản

Trước đây, để phát âm thanh trên web ta dùng thẻ <audio>. Nhưng với game, thẻ này có nhiều giới hạn nghiêm trọng: độ trễ (latency) cao, khó phát chồng nhiều âm cùng lúc (overlap), và gần như không thể tổng hợp âm thanh động theo thời gian thực. Web Audio API giải quyết tất cả: nó cung cấp một đồ thị xử lý âm thanh (audio graph) chạy trên luồng audio riêng của trình duyệt, với độ trễ cực thấp (thường < 20ms) và khả năng tạo, lọc, trộn âm thanh ở mức mẫu (sample).

Trung tâm của mọi thứ là AudioContext — đối tượng quản lý toàn bộ đồ thị âm thanh. Mọi nguồn âm (oscillator, buffer, mic...) đều được nối qua các node trung gian (gain, filter, analyser) rồi cuối cùng đổ vào ctx.destination (chính là loa của bạn).

audio_context_basics.js
// Tạo một AudioContext (lưu ý prefix cho Safari cũ)
const AudioCtx = window.AudioContext || window.webkitAudioContext;
const ctx = new AudioCtx();

// Đồ thị âm thanh tối giản: Nguồn -> Gain (âm lượng) -> Loa
const osc = ctx.createOscillator();   // nguồn phát sóng
const gain = ctx.createGain();        // điều khiển âm lượng

osc.connect(gain);
gain.connect(ctx.destination);        // ctx.destination = loa thiết bị

osc.frequency.value = 440;            // 440Hz = nốt La (A4)
gain.gain.value = 0.2;                // âm lượng 20%
osc.start();                          // bắt đầu phát

Có một "luật ngầm" cực kỳ quan trọng: trình duyệt chặn âm thanh tự động phát. Vì lý do trải nghiệm người dùng (không ai muốn website tự gào lên), một AudioContext mới tạo ra sẽ ở trạng thái suspended cho đến khi có một user gesture (click, chạm, nhấn phím) thực sự xảy ra. Bạn phải gọi ctx.resume() bên trong handler của sự kiện đó để "mở khóa" âm thanh.

audio_unlock_gesture.js
let ctx = null;

// Khởi tạo / mở khóa audio bên trong một user gesture
function ensureAudio() {
  if (!ctx) {
    const AudioCtx = window.AudioContext || window.webkitAudioContext;
    ctx = new AudioCtx();
  }
  // Trình duyệt giữ context ở 'suspended' đến khi user tương tác
  if (ctx.state === 'suspended') ctx.resume();
  return ctx;
}

// Gắn vào lần click đầu tiên
document.addEventListener('click', () => ensureAudio(), { once: false });

2. Tạo SFX bằng Oscillator

Điều tuyệt vời nhất của Web Audio API với game indie là: bạn không cần bất kỳ file âm thanh nào. Toàn bộ tiếng beep, blip, coin, jump, explosion đều có thể tổng hợp trực tiếp bằng OscillatorNode. Một oscillator phát ra một dạng sóng tuần hoàn ở tần số nhất định, với 4 dạng cơ bản: sine (mềm, sạch), square (chói, kiểu 8-bit), sawtooth (gắt, dày), và triangle (giữa sine và square).

Nhưng oscillator phát ở âm lượng cố định nghe rất "máy móc" và khó chịu. Bí quyết để âm thanh nghe "thật" là envelope — sự thay đổi âm lượng theo thời gian. Mô hình kinh điển là ADSR: Attack (âm dâng lên nhanh), Decay (giảm xuống mức duy trì), Sustain (giữ ở mức đó), Release (tắt dần về 0). Với SFX game ngắn, ta thường chỉ cần Attack nhanh + Release dài (gọi là "pluck envelope").

Ta điều khiển envelope qua GainNode.gain bằng các phương thức tự động hóa thời gian như setValueAtTime, linearRampToValueAtTimeexponentialRampToValueAtTime — chúng được lập lịch chính xác trên đồng hồ audio ctx.currentTime (tính bằng giây).

sfx_blip.js
// Một "blip" UI ngắn gọn với pluck envelope
function playBlip(ctx, freq = 660) {
  const t = ctx.currentTime;
  const osc = ctx.createOscillator();
  const gain = ctx.createGain();

  osc.type = 'square';            // chất 8-bit
  osc.frequency.setValueAtTime(freq, t);

  // Envelope: 0 -> đỉnh trong 5ms (attack), rồi tắt dần trong 120ms
  gain.gain.setValueAtTime(0.0001, t);
  gain.gain.exponentialRampToValueAtTime(0.3, t + 0.005);
  gain.gain.exponentialRampToValueAtTime(0.0001, t + 0.12);

  osc.connect(gain).connect(ctx.destination);
  osc.start(t);
  osc.stop(t + 0.13);             // dọn dẹp node tự động sau khi tắt
}

Để có tiếng "coin" kiểu Mario, ta dùng một oscillator nhảy tần số đột ngột lên cao hơn (hai nốt nối tiếp). Việc lập lịch setValueAtTime tại hai mốc thời gian tạo ra hiệu ứng "ting-ting" đặc trưng.

sfx_coin.js
function playCoin(ctx) {
  const t = ctx.currentTime;
  const osc = ctx.createOscillator();
  const gain = ctx.createGain();
  osc.type = 'square';

  // Hai nốt: B5 rồi nhảy lên E6 sau 70ms
  osc.frequency.setValueAtTime(988, t);
  osc.frequency.setValueAtTime(1319, t + 0.07);

  gain.gain.setValueAtTime(0.25, t);
  gain.gain.exponentialRampToValueAtTime(0.0001, t + 0.3);

  osc.connect(gain).connect(ctx.destination);
  osc.start(t);
  osc.stop(t + 0.32);
}

Còn tiếng "explosion" thì không dùng oscillator tuần hoàn mà dùng noise (nhiễu trắng) — một buffer chứa các giá trị ngẫu nhiên — đẩy qua một BiquadFilterNode kiểu lowpass có tần số cắt giảm dần, tạo cảm giác "bùm" trầm rồi tắt.

sfx_explosion.js
function playExplosion(ctx) {
  const t = ctx.currentTime;
  const dur = 0.5;

  // Tạo buffer noise trắng dài 0.5s
  const buffer = ctx.createBuffer(1, ctx.sampleRate * dur, ctx.sampleRate);
  const data = buffer.getChannelData(0);
  for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1;

  const noise = ctx.createBufferSource();
  noise.buffer = buffer;

  const filter = ctx.createBiquadFilter();
  filter.type = 'lowpass';
  filter.frequency.setValueAtTime(1200, t);
  filter.frequency.exponentialRampToValueAtTime(60, t + dur); // "bùm" trầm dần

  const gain = ctx.createGain();
  gain.gain.setValueAtTime(0.6, t);
  gain.gain.exponentialRampToValueAtTime(0.0001, t + dur);

  noise.connect(filter).connect(gain).connect(ctx.destination);
  noise.start(t);
  noise.stop(t + dur);
}

3. Audio Visualization với AnalyserNode

Để vẽ những thanh tần số nhảy múa theo nhạc (kiểu equalizer) hay đường sóng (waveform) lên canvas, ta cần một node đặc biệt: AnalyserNode. Nó "lắng nghe" tín hiệu đi qua nó và cho phép ta lấy ra dữ liệu phổ tần số hoặc dạng sóng theo thời gian thực — mà không làm thay đổi âm thanh.

Hai phương thức chính: getByteFrequencyData(arr) trả về biên độ của từng dải tần (kết quả phân tích FFT, dùng để vẽ frequency bars), và getByteTimeDomainData(arr) trả về dạng sóng thô (dùng để vẽ oscilloscope/waveform). Thuộc tính fftSize quyết định độ phân giải — số dải tần bằng fftSize / 2, gọi là frequencyBinCount.

Bạn nối analyser vào đồ thị: nguồn → analyser → destination. Việc đọc dữ liệu được thực hiện trong vòng lặp requestAnimationFrame của canvas, đồng bộ với mỗi khung hình vẽ.

analyser_setup.js
const analyser = ctx.createAnalyser();
analyser.fftSize = 256;                 // -> 128 dải tần
const bins = analyser.frequencyBinCount; // = fftSize / 2 = 128
const freqData = new Uint8Array(bins);   // mảng tái sử dụng (0..255)

// Nối analyser xen vào giữa: gain -> analyser -> loa
gain.connect(analyser);
analyser.connect(ctx.destination);

Trong vòng lặp vẽ, mỗi khung ta đổ dữ liệu mới vào mảng rồi map từng dải tần thành một thanh dọc. Vì giá trị nằm trong khoảng 0–255, ta chia cho 255 để chuẩn hóa về tỉ lệ chiều cao canvas.

draw_frequency_bars.js
function drawBars(ctx2d, analyser, freqData, W, H) {
  analyser.getByteFrequencyData(freqData);     // cập nhật dữ liệu phổ
  ctx2d.clearRect(0, 0, W, H);

  const n = freqData.length;
  const barW = W / n;
  for (let i = 0; i < n; i++) {
    const v = freqData[i] / 255;               // chuẩn hóa 0..1
    const barH = v * H;
    const hue = (i / n) * 280;                 // gradient theo dải tần
    ctx2d.fillStyle = `hsl(${hue}, 90%, 60%)`;
    ctx2d.fillRect(i * barW, H - barH, barW - 1, barH);
  }
  requestAnimationFrame(() => drawBars(ctx2d, analyser, freqData, W, H));
}

4. Game Juice: Screen Shake

Screen shake (rung màn hình) là kỹ thuật juice rẻ tiền nhất nhưng hiệu quả bậc nhất: khi nhân vật trúng đòn hay có vụ nổ, cả khung hình rung lên giây lát khiến não bộ cảm nhận được "lực". Cách làm ngây thơ là cộng một offset ngẫu nhiên vào tọa độ vẽ — nhưng nó thường giật cục và khó kiểm soát.

Kỹ thuật chuẩn mực (do Vlambeer phổ biến) là trauma-based shake: ta giữ một biến trauma trong [0, 1]. Mỗi sự kiện cộng thêm trauma (và clamp tối đa 1). Mỗi khung hình, trauma tự giảm theo thời gian (decay). Cường độ rung thực tế dùng trauma² hoặc trauma³ — bình phương giúp rung tắt mượt và tự nhiên hơn nhiều so với tuyến tính.

screen_shake_trauma.js
let trauma = 0;                       // [0, 1]
const MAX_OFFSET = 24;                 // độ rung tối đa (px)
const DECAY = 1.5;                     // tốc độ giảm / giây

function addTrauma(amount) {
  trauma = Math.min(1, trauma + amount);
}

function applyShake(ctx, dt) {
  if (trauma > 0) {
    const shake = trauma * trauma;     // bình phương -> mượt
    const dx = (Math.random() * 2 - 1) * MAX_OFFSET * shake;
    const dy = (Math.random() * 2 - 1) * MAX_OFFSET * shake;
    ctx.translate(dx, dy);             // dịch toàn bộ khung vẽ
    trauma = Math.max(0, trauma - DECAY * dt);
  }
}

Điểm mấu chốt: gọi applyShake sau ctx.save() ở đầu khung hình và ctx.restore() ở cuối, để offset chỉ áp dụng cho lần vẽ này mà không tích lũy. Để chuyên nghiệp hơn, thay Math.random bằng noise Perlin/Simplex giúp rung "lượn" thay vì nhảy loạn.

shake_render_loop.js
function render(ctx, dt) {
  ctx.save();              // 1. lưu trạng thái sạch
  applyShake(ctx, dt);     // 2. áp dụng dịch chuyển rung
  drawWorld(ctx);          // 3. vẽ thế giới (đã bị rung theo)
  ctx.restore();           // 4. khôi phục -> không tích lũy offset
}

// Ví dụ kích hoạt khi va chạm:
function onHit() { addTrauma(0.6); }

5. Game Juice: Hit feedback & Particles burst

Khi một vật bị bắn trúng, ngoài screen shake ta thường chồng thêm ba lớp phản hồi tức thời: hit flash (lóe trắng trong vài khung hình), scale punch (vật phình to rồi co lại — squash & stretch), và particle burst (mảnh vỡ bắn tung tóe). Ba thứ này cộng lại tạo cảm giác "chạm thật" rất mạnh.

Particle burst là một mảng các hạt, mỗi hạt có vị trí, vận tốc (theo góc tỏa đều quanh điểm va chạm), thời gian sống (life) và kích thước. Mỗi khung hình ta cập nhật vị trí theo vận tốc, áp lực ma sát/trọng lực, giảm life, và xóa hạt đã chết.

particle_burst.js
const particles = [];

function spawnBurst(x, y, count = 24) {
  for (let i = 0; i < count; i++) {
    const ang = (i / count) * Math.PI * 2 + Math.random() * 0.3;
    const speed = 120 + Math.random() * 180;       // px/giây
    particles.push({
      x, y,
      vx: Math.cos(ang) * speed,
      vy: Math.sin(ang) * speed,
      life: 1,                                       // 1 -> 0
      size: 2 + Math.random() * 3,
      hue: 30 + Math.random() * 40                   // tông cam/vàng
    });
  }
}

function updateParticles(dt) {
  for (let i = particles.length - 1; i >= 0; i--) {
    const p = particles[i];
    p.x += p.vx * dt;
    p.y += p.vy * dt;
    p.vy += 400 * dt;            // trọng lực
    p.vx *= 0.94; p.vy *= 0.94;  // ma sát không khí
    p.life -= dt * 1.6;
    if (p.life <= 0) particles.splice(i, 1);  // dọn hạt chết
  }
}

Khi vẽ, ta dùng life làm độ mờ (alpha) để hạt nhạt dần khi sắp biến mất, và dùng nó để thu nhỏ kích thước. Riêng hit flash và scale punch được điều khiển bằng một biến punch giảm dần — nhân vào scale của vật và độ sáng lớp phủ trắng.

draw_juice_feedback.js
function drawParticles(ctx) {
  for (const p of particles) {
    ctx.globalAlpha = Math.max(0, p.life);     // nhạt dần
    ctx.fillStyle = `hsl(${p.hue}, 100%, 60%)`;
    ctx.beginPath();
    ctx.arc(p.x, p.y, p.size * p.life, 0, Math.PI * 2);
    ctx.fill();
  }
  ctx.globalAlpha = 1;
}

// Scale punch: vật phình ra rồi co về 1 theo 'punch'
function drawTarget(ctx, x, y, base, punch) {
  const s = base * (1 + punch * 0.6);          // punch: 1 -> 0
  ctx.save();
  ctx.translate(x, y);
  ctx.scale(s / base, s / base);
  ctx.fillStyle = '#06b6d4';
  ctx.fillRect(-base / 2, -base / 2, base, base);
  // hit flash: lớp phủ trắng theo punch
  ctx.fillStyle = `rgba(255,255,255,${punch})`;
  ctx.fillRect(-base / 2, -base / 2, base, base);
  ctx.restore();
}

6. Easing-based juice

Lớp juice tinh tế nhất nằm ở cách mọi thứ chuyển động, chứ không chỉ ở chuyện nó có chuyển động hay không. Đây chính là nơi các hàm easing (đã học ở bài Easing & Tweening) tỏa sáng. Ba nguyên tắc animation kinh điển của Disney rất đáng áp dụng: squash & stretch (vật co giãn theo lực — phình ngang khi tiếp đất, kéo dài khi bật lên), anticipation (thu nhỏ/lùi lại một nhịp trước khi bung ra), và follow-through (các phần phụ tiếp tục chuyển động một chút sau khi vật chính dừng lại).

Một easing "đàn hồi" như easeOutBack (vượt quá đích rồi nảy về) hay easeOutElastic biến một chuyển động phẳng thành một chuyển động "có hồn". Ví dụ dưới minh họa scale punch dùng easeOutBack để vật bung ra vượt cỡ rồi ổn định — đúng tinh thần squash & stretch.

easing_juice.js
// easeOutBack: vượt đích rồi nảy về (overshoot) -> cảm giác đàn hồi
function easeOutBack(t) {
  const c1 = 1.70158, c3 = c1 + 1;
  return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
}

// Khi nhặt item: scale từ 0 -> 1 với overshoot, kèm squash nhẹ
function popItem(ctx, x, y, size, t) {     // t: 0..1 tiến trình
  const e = easeOutBack(Math.min(1, t));
  const stretch = 1 + (1 - e) * 0.25;      // hơi dẹt khi đang bung
  ctx.save();
  ctx.translate(x, y);
  ctx.scale(e * stretch, e / stretch);     // squash & stretch
  ctx.fillStyle = '#facc15';
  ctx.beginPath();
  ctx.arc(0, 0, size, 0, Math.PI * 2);
  ctx.fill();
  ctx.restore();
}

Demo tương tác

Hãy tự tay cảm nhận game juice. Demo đầu tiên kết hợp screen shake + particle burst + scale punch (thuần thị giác). Demo thứ hai tạo âm thanh hoàn toàn bằng Oscillator và vẽ waveform thời gian thực — nhớ rằng âm thanh chỉ phát sau khi bạn click (do luật user-gesture).

💥 Demo tương tác: Screen Shake + Particle Juice
🔊 Demo tương tác: Synth SFX + Waveform

7. Câu hỏi trắc nghiệm ôn tập

Trắc nghiệm 1: User Gesture

Vì sao âm thanh tạo bằng Web Audio API thường không phát ngay khi trang vừa tải?

Trắc nghiệm 2: ADSR Envelope

Vai trò của GainNode envelope (ADSR) khi tạo SFX bằng Oscillator là gì?

Trắc nghiệm 3: Trauma-based Screen Shake

Vì sao screen shake chuẩn dùng trauma² thay vì trauma tuyến tính cho cường độ rung?

Tải file code thực hành minh họa bài học

File chứa toàn bộ engine âm thanh procedural (Oscillator SFX), AnalyserNode visualization và hệ thống juice (screen shake, particles, easing punch):

Tải về canvas_audio.js

Related Articles

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

Lesson 16: Data Visualization: Charts & Graphs from Scratch Bài 16: Data Visualization — Charts & Graphs từ Scratch Lesson 14: Game Development: Entity System & Mini Platformer Bài 14: Game Development — Entity System & Mini Platformer Back to Canvas Series Overview Quay lại Lộ trình Canvas Series