Một tiếng “ting” ngắn làm nút game có cảm giác thật hơn hẳn, nhưng Web Audio API ban đầu hơi khó nhìn vì mọi thứ là node và dây nối. Mình bắt đầu bằng âm thanh nhặt coin: một oscillator tạo sóng, một gain hạ âm lượng và vài mốc thời gian để tiếng tắt gọn.
Hàm playCoin được test bằng context giả trong Node để kiểm tra thứ tự start/stop và tránh ramp mũ tới 0. Test không chứng minh âm thanh “hay”, nhưng chặn được vài lỗi khiến nó không kêu.
Context bắt đầu ở trạng thái suspended
Trình duyệt chặn tiếng tự phát trước khi có cử chỉ của người dùng. AudioContext mới thường ở suspended. Gọi resume trong handler của click hoặc phím, rồi mới phát.
Tạo một context cho cả trang và giữ nó. Đừng new AudioContext() mỗi lần kêu: trình duyệt giới hạn số context, và mỗi cái tốn tài nguyên. Tạo oscillator mới cho mỗi tiếng, vì oscillator một lần stop thì không start lại.
export function playCoin(context) {
const now = context.currentTime;
const osc = context.createOscillator();
const gain = context.createGain();
osc.type = "square";
osc.frequency.setValueAtTime(880, now);
osc.frequency.exponentialRampToValueAtTime(1320, now + 0.08);
gain.gain.setValueAtTime(0.0001, now);
gain.gain.exponentialRampToValueAtTime(0.2, now + 0.01);
gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.18);
osc.connect(gain);
gain.connect(context.destination);
osc.start(now);
osc.stop(now + 0.2);
}Đồ thị là oscillator nối vào gain, gain nối vào destination. Gain là núm vặn. Không có nó, oscillator đi thẳng ra loa ở biên độ đầy đủ của dạng sóng, dễ khiến người nghe giật mình. Biên độ đỉnh 0.2 trong ví dụ là một mức thấp hơn full scale, không phải một ngưỡng an toàn đã được chuẩn hóa. Hãy nghe trên máy bạn và giảm nếu vẫn quá to.
Ramp mũ không được tới 0
exponentialRampToValueAtTime cần giá trị đích lớn hơn 0. Ramp tới 0 ném lỗi hoặc không chạy như bạn tưởng, tùy trình duyệt. Ví dụ dùng 0.0001 làm mức sàn, đủ nhỏ để coi như im, đủ lớn để ramp hợp lệ.
stop được đặt sau start. Thời điểm stop nhỏ hơn hoặc bằng thời điểm start làm tiếng không phát hoặc bị cắt ngay. Các mốc trong hàm đều cộng thêm từ currentTime, không dùng Date.now. Đồng hồ của audio context và đồng hồ của trang không hoán đổi cho nhau.
setValueAtTime đặt điểm neo trước ramp. Thiếu điểm neo, ramp có thể tính từ giá trị mặc định mà bạn không định.
Gắn vào một nút, rồi dọn
Trong React, đừng tạo context ở thân module. Module có thể được đánh giá trong môi trường không có window lúc build. Tạo context trong handler:
let audioContext: AudioContext | null = null;
function getAudioContext(): AudioContext {
if (!audioContext) audioContext = new AudioContext();
return audioContext;
}
export async function onPress() {
const context = getAudioContext();
if (context.state === "suspended") await context.resume();
playCoin(context);
}Biến audioContext ở đây nằm trong module của widget client, sau khi đã vào trình duyệt. Nếu bạn muốn đóng context khi rời trang, gọi close trong cleanup và gán lại null. Đừng close sau mỗi tiếng: lần bấm sau phải tạo context mới và lại cần một cử chỉ để resume.
Nếu game có nút mute, đừng hủy context. Nhân gain với 0 hoặc bỏ qua lời gọi playCoin. Boolean mute có thể nằm trong save, như bài localStorage đã nói, nhưng quyết định có gọi playCoin hay không thuộc state trong bộ nhớ.
File âm thanh là đường khác
fetch một file rồi decodeAudioData cho ra AudioBuffer. Bạn phát buffer bằng AudioBufferSourceNode, cũng là node một lần. Oscillator hợp với tiếng tổng hợp. Buffer hợp với tiếng đã thu. Đừng decode lại cùng một file mỗi lần bấm; giữ buffer.
File tĩnh trên một site export chỉ là URL trong public/. Không có server xử lý audio. Nếu file 404, decodeAudioData không chạy. Hãy kiểm tra response trước khi decode.
Autoplay một soundtrack lúc tải trang sẽ bị chặn, và cũng khó chịu với người đang đọc một bài viết. Một bài blog không nên phát tiếng khi scroll. Game thì đợi người chơi bấm Bắt đầu, rồi mới resume.
Những việc không nên làm
Đừng nối nhiều oscillator vô hạn mà không stop. Chúng chạy cho đến khi context đóng.
Đừng đặt tần số 0 vào exponentialRampToValueAtTime. Cùng quy tắc với gain: đích của ramp mũ phải dương. Đổi tần số tuyến tính thì dùng linearRampToValueAtTime, hàm này chấp nhận 0.
Đừng dùng Web Audio để phát một đoạn chữ dài thay cho nội dung trang. Đó là âm thanh phụ, không phải cách đưa thông tin chính tới người không nghe được. Nút vẫn cần nhãn chữ. Bài giao diện game nói về nút đó trên mobile.
Đừng giả định mọi máy có cùng độ trễ. currentTime là đồng hồ của context, không phải milliseconds hiển thị. Nếu bạn cần tiếng khớp animation, lên lịch bằng thời gian của context, rồi chấp nhận một độ trễ nhỏ của thiết bị.
Kiểm tra một tiếng mới
- Context được
resumetrong cử chỉ, không phải lúc import module. - Mỗi lần kêu tạo node mới, nối gain, rồi mới
start. - Không có ramp mũ tới 0 hoặc tới tần số 0.
stopnằm saustart.- Có cách tắt tiếng không phụ thuộc vào việc xóa context.
Năm mục đó đủ cho hiệu ứng ngắn. Nhạc nền có vòng lặp, nhiều track, và fade giữa cảnh là một lớp khác: bạn sẽ cần lịch currentTime cẩn thận hơn và một chỗ giữ các node đang chạy để dừng chúng khi đổi cảnh. Đừng bắt đầu từ thư viện nếu một oscillator chưa kêu đúng.