用 Tone.js 做游戏音频
Tone.js 构建在 Web Audio API 之上,让合成音频变得实用。你不用加载音频文件,而是用程序生成声音。这意味着零加载时间、无限的变化,以及能实时响应玩法的声音。
试一试(点击按钮听合成的游戏音效):
为什么用 Tone.js 而不是音频文件
音频文件适合做音乐和复杂的声音。但对于游戏音效,合成有真正的优势。你可以每次播放都改变音高和时长,让声音不重复。你可以基于游戏状态即时生成声音。而且完全跳过网络请求。
缺点是合成音频比直接播放文件更占 CPU。对简单游戏来说不是问题。对复杂游戏,你可能两种方式混着用。
安装
从 CDN 或 npm 引入 Tone.js:
<script src="https://unpkg.com/tone@15/build/Tone.js"></script>或用 npm:
npm install toneimport * as Tone from 'tone'自动播放问题
浏览器在用户交互前阻止音频播放。Tone.js 会处理这件事,但你需要在用户手势里调用 Tone.start():
document.addEventListener('click', async () => {
await Tone.start()
console.log('Audio ready')
}, { once: true })不写这个,AudioContext 会一直挂起,什么都不会播。
你的第一个合成器
合成器生成声音。最简单的是 Tone.Synth:
const synth = new Tone.Synth().toDestination()
// 播一个音
synth.triggerAttackRelease('C4', '8n')triggerAttackRelease 按时长播放一个音符。'C4' 是中央 C。'8n' 在当前节奏下是八分音符。
合成器包含一个振荡器(声源)和一个包络(声音如何淡入淡出)。两者都可以自定义:
const synth = new Tone.Synth({
oscillator: { type: 'triangle' },
envelope: {
attack: 0.02,
decay: 0.2,
sustain: 0.3,
release: 0.4,
},
}).toDestination()振荡器类型包含 sine(纯净、柔和)、triangle(柔)、sawtooth(嗡嗡声)和 square(空洞、复古)。
构建音效系统
对游戏来说,你需要多个合成器来对应不同的声音类型。这是一个好用的模式:
let isInitialized = false
let softSynth = null
let subSynth = null
let noiseSynth = null
let sfxGain = null
function initSFX() {
if (isInitialized) return
// 主音量控制
sfxGain = new Tone.Gain(0.7).toDestination()
// 柔和的合成器用于旋律性声音
softSynth = new Tone.PolySynth(Tone.Synth, {
maxPolyphony: 6,
oscillator: { type: 'sine' },
envelope: {
attack: 0.02,
decay: 0.2,
sustain: 0.3,
release: 0.4,
},
volume: -8,
}).connect(sfxGain)
// 用于深沉冲击的低频
subSynth = new Tone.Synth({
oscillator: { type: 'sine' },
envelope: {
attack: 0.02,
decay: 0.3,
sustain: 0.1,
release: 0.3,
},
volume: -8,
}).connect(sfxGain)
// 用于质感的噪声
noiseSynth = new Tone.NoiseSynth({
noise: { type: 'pink' },
envelope: {
attack: 0.02,
decay: 0.15,
sustain: 0,
release: 0.15,
},
volume: -20,
}).connect(sfxGain)
isInitialized = true
}PolySynth 让你同时播多个音。普通 Synth 是单音的。
游戏音效示例
一些常见的游戏音效以及制作方式:
收集金币——上行音符让人觉得有奖励感:
function coinCollect() {
const now = Tone.now()
softSynth.triggerAttackRelease('C4', '8n', now, 0.3)
softSynth.triggerAttackRelease('E4', '8n', now + 0.1, 0.35)
softSynth.triggerAttackRelease('G4', '4n', now + 0.2, 0.4)
}跳跃——快速上行滑音:
function jump() {
const synth = new Tone.Synth({
oscillator: { type: 'square' },
envelope: { attack: 0.01, decay: 0.1, sustain: 0, release: 0.1 },
}).toDestination()
const now = Tone.now()
synth.frequency.setValueAtTime(150, now)
synth.frequency.exponentialRampToValueAtTime(400, now + 0.1)
synth.triggerAttackRelease('C4', '8n', now, 0.2)
}受击/伤害——下行 + 噪声:
function hit() {
const now = Tone.now()
subSynth.triggerAttackRelease('E2', '4n', now, 0.5)
noiseSynth.triggerAttackRelease('4n', now, 0.25)
}死亡——悲伤的下行音符:
function death() {
const now = Tone.now()
softSynth.triggerAttackRelease('E4', '4n', now, 0.4)
softSynth.triggerAttackRelease('C4', '4n', now + 0.2, 0.35)
softSynth.triggerAttackRelease('A3', '4n', now + 0.4, 0.3)
}添加效果
效果让声音更丰富。把它们串在合成器和输出之间:
const filter = new Tone.Filter({
frequency: 3000,
type: 'lowpass',
rolloff: -12,
})
const reverb = new Tone.Reverb({
decay: 1.2,
wet: 0.25,
})
const gain = new Tone.Gain(0.7)
// 链条:synth -> filter -> reverb -> gain -> destination
filter.connect(reverb)
reverb.connect(gain)
gain.toDestination()
const synth = new Tone.Synth().connect(filter)低通滤波器切掉刺耳的高频。混响增加空间感。Gain 控制音量。
限流
如果游戏快速触发很多声音,它们会重叠成一团糟。加上限流:
const MIN_INTERVAL_MS = 35
let lastNoteTime = 0
function canPlayNote() {
const now = performance.now()
if (now - lastNoteTime < MIN_INTERVAL_MS) {
return false
}
lastNoteTime = now
return true
}
function playSound() {
if (!canPlayNote()) return
synth.triggerAttackRelease('C4', '8n')
}动态背景音乐
让音乐响应玩法,用基于拍子的系统:
const BPM = 140
const BEAT_MS = (60 / BPM) * 1000
let bassSynth = null
let kickDrum = null
let beatInterval = null
let beatCount = 0
let energy = 0.3
const PROGRESSION = [
{ root: 'E2', chord: ['E3', 'G3', 'B3'] },
{ root: 'C2', chord: ['C3', 'E3', 'G3'] },
{ root: 'D2', chord: ['D3', 'F#3', 'A3'] },
{ root: 'A1', chord: ['A2', 'C3', 'E3'] },
]
function playBeat() {
const beatInMeasure = beatCount % 8
const chordIndex = Math.floor(beatCount / 8) % PROGRESSION.length
const chord = PROGRESSION[chordIndex]
const now = Tone.now()
// 第 1 拍的低音
if (beatInMeasure === 0) {
bassSynth.triggerAttackRelease(chord.root, '4n', now, 0.6)
}
// 底鼓——只在能量高时
if (beatInMeasure === 0 && energy > 0.3) {
kickDrum.triggerAttackRelease('C1', '8n', now, 0.4)
}
beatCount++
}
function startMusic() {
beatInterval = setInterval(playBeat, BEAT_MS)
}
function stopMusic() {
clearInterval(beatInterval)
}
// 游戏事件影响能量
function onEnemyKill() {
energy = Math.min(1, energy + 0.1)
}音乐强度会随着游戏发生的事而变化。
用 MembraneSynth 和 NoiseSynth 做鼓
打击乐用专用合成器:
// 底鼓
const kick = new Tone.MembraneSynth({
pitchDecay: 0.05,
octaves: 4,
envelope: {
attack: 0.001,
decay: 0.2,
sustain: 0,
release: 0.1,
},
volume: -14,
}).toDestination()
kick.triggerAttackRelease('C1', '8n')
// 军鼓
const snare = new Tone.NoiseSynth({
noise: { type: 'white' },
envelope: {
attack: 0.002,
decay: 0.15,
sustain: 0,
release: 0.1,
},
volume: -12,
}).toDestination()
snare.triggerAttackRelease('8n')
// 踩镲
const hihat = new Tone.NoiseSynth({
noise: { type: 'white' },
envelope: {
attack: 0.001,
decay: 0.04,
sustain: 0,
release: 0.03,
},
volume: -22,
}).toDestination()
hihat.triggerAttackRelease('32n')清理
不用时销毁合成器,释放资源:
function cleanup() {
if (softSynth) softSynth.dispose()
if (subSynth) subSynth.dispose()
if (noiseSynth) noiseSynth.dispose()
if (sfxGain) sfxGain.dispose()
}常见错误
没等 Tone.start()——音频上下文必须在用户交互里启动,不然不会播。
每次发声都新建合成器——init 时创建一次反复使用。每次都新建会泄漏内存。
太多重叠声音——用限流和去重来防止音频糊成一团。
刺耳频率——用低通滤波器和更柔和的振荡器类型(sine、triangle),玩家就不会反感。
更多资源
游戏的 Web Audio API 涵盖底层 API 和音频文件加载。
Tone.js 文档 提供完整 API 参考。
去哪里找免费游戏素材 包含 Freesound 等音频与音效来源。
移动端友好的网页游戏 涵盖 iOS 音频限制和触摸解锁模式。