
HexaZen - EP03:音效播放引擎
上一章我們設計了聲景規則系統,知道什麼時候該播放什麼音效,但是「播放音效」不是只有播放就好,有些小細節需要注意。ԅ(´∀` ԅ)
例如 loop 音效循環到起點時,會有明顯的「接縫感」,這章就來解決這個問題!◝( •ω• )◟
型別定義
先來看看 Soundscape 的資料結構。
content\aquarium\hexazen\domains\soundscape\type.ts
export type SoundscapeType =
| 'rustle'
| 'insect'
| 'bird'
| 'frog'
| 'beast'
| 'river'
| 'building'
| 'ocean'
| 'alpine'
| 'rain'
| 'campfire'
interface Sound {
src: string;
/** 0 ~ 1
* @default 0.5
*/
volume?: number;
}
export interface Soundscape {
id: number;
title: string;
type: SoundscapeType;
mode:
| {
value: 'loop';
}
| {
value: 'interval';
/** @default [5, 10] 秒 */
range?: [number, number];
};
soundList: Sound[];
}目前 mode 分為兩種播放模式:
- loop:適合持續性音效(風聲、流水、雨聲)
- interval:適合間歇性音效(鳥叫、蛙鳴、雷聲),會在隨機間隔後播放
GainNode 串接架構
音效系統的核心是 GainNode,藉由多個 Node 串接,實現方便、彈性的音效控制。
音量控制鏈如下:
trackGain → baseGain → globalGain → muteGain → destination每一層各司其職:
| GainNode | 控制者 | 用途 |
|---|---|---|
trackGain | 音效定義 | 單軌基礎音量(如鳥叫 0.5、雷聲 0.1) |
baseGain | 混音器 | 使用者在混音器 UI 中調整的乘數 |
globalGain | 主控音量 | 左側滑桿的整體音量 |
muteGain | 靜音按鈕 | 0 或 1,控制靜音 |
constructor(soundscape: Soundscape) {
this.audioContext = getAudioContext()
// 建立 GainNode 鏈
this.baseGainNode = this.audioContext.createGain()
this.globalGainNode = this.audioContext.createGain()
this.muteGainNode = this.audioContext.createGain()
this.baseGainNode.connect(this.globalGainNode)
this.globalGainNode.connect(this.muteGainNode)
this.muteGainNode.connect(this.audioContext.destination)
}TIP
所有 SoundscapePlayer 共用同一個 AudioContext,因為瀏覽器對 AudioContext 的數量有限制。
Loop 模式:雙軌交叉淡入淡出
Loop 模式的核心技巧是雙軌交疊(crossfade)。
建立兩個 Audio 元素播放同一段音檔,在 A 軌快結束前提早啟動 B 軌,兩軌重疊的期間自然過渡,就聽不到接縫了。
private playLoop() {
const soundData = this.soundscape.soundList[0]
if (!soundData) return
const baseVolume = soundData.volume ?? DEFAULT_BASE_VOLUME
// 建立雙音軌
const audioA = new Audio(soundData.src)
audioA.crossOrigin = 'anonymous'
audioA.loop = true
const audioB = new Audio(soundData.src)
audioB.crossOrigin = 'anonymous'
audioB.loop = true
const trackA = this.createTrack(audioA, baseVolume)
const trackB = this.createTrack(audioB, baseVolume)
let useTrackA = true
const scheduleNext = (track: AudioTrack) => {
if (this.isDestroying) return
track.audio.currentTime = 0
track.audio.play().catch((e) =>
console.warn(`[${this.soundscape.type}] 播放被阻擋:`, e),
)
const setupOverlap = () => {
// 確保交疊時間不會大於音檔本身長度的一半
const overlap = Math.min(
this.OVERLAP_SECONDS,
track.audio.duration * 0.4,
)
const triggerTimeMs = (track.audio.duration - overlap) * 1000
const timer = setTimeout(() => {
this.timeoutIds.delete(timer)
useTrackA = !useTrackA
const nextTrack = useTrackA ? trackA : trackB
scheduleNext(nextTrack)
}, triggerTimeMs)
this.timeoutIds.add(timer)
}
if (track.audio.readyState >= 1) {
setupOverlap()
} else {
track.audio.onloadedmetadata = () => {
setupOverlap()
track.audio.onloadedmetadata = null
}
}
}
scheduleNext(trackA)
}OVERLAP_SECONDS 預設為 4 秒,但不會超過音檔長度的 40%,避免短音檔出問題。( •̀ ω •́ )✧
Interval 模式:隨機間隔播放
Interval 模式適合像鳥叫、蛙鳴這類間歇性聲音。
每次從 soundList 中隨機挑一個音效播放,播完後等待隨機秒數再播下一個。
private playInterval() {
if (this.isDestroying) return
const [randomSound] = sample(this.soundscape.soundList, 1)
if (!randomSound) return
const baseVolume = randomSound.volume ?? DEFAULT_BASE_VOLUME
const audio = new Audio(randomSound.src)
audio.crossOrigin = 'anonymous'
const track = this.createTrack(audio, baseVolume)
audio.onended = () => {
this.removeTrack(track)
if (this.isDestroying) return
const { mode } = this.soundscape
const [min, max] = mode.value === 'interval' && mode.range
? [Math.min(...mode.range), Math.max(...mode.range)]
: [5, 10]
const waitSec = Math.random() * (max - min) + min
const timer = setTimeout(() => {
this.timeoutIds.delete(timer)
this.playInterval()
}, waitSec * 1000)
this.timeoutIds.add(timer)
}
audio.play().catch((e) => {
console.warn(`[${this.soundscape.type}] 播放被阻擋:`, e)
})
}預設等待時間為 5~10 秒的隨機區間,雷聲則特別設定為 10~30 秒,讓間隔更有真實感。
優雅地銷毀
當積木被移除或天氣變化導致音效不再需要時,不能突然靜音,會顯得相當突兀。
這裡用 linearRampToValueAtTime 做 2 秒的漸出:
public async destroy(fadeOutDurationMs = 2000): Promise<void> {
if (this.isDestroying) return
this.isDestroying = true
// 1. 清除所有等待中的計時器
this.timeoutIds.forEach((timer) => clearTimeout(timer))
this.timeoutIds.clear()
// 2. 對所有正在播放的音軌執行漸出
const currentTime = this.audioContext.currentTime
const fadeEndTime = currentTime + fadeOutDurationMs / 1000
for (const track of this.activeTracks) {
track.trackGain.gain.setValueAtTime(
track.trackGain.gain.value, currentTime,
)
track.trackGain.gain.linearRampToValueAtTime(0, fadeEndTime)
}
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, fadeOutDurationMs)
this.timeoutIds.add(timer)
})
// 3. 徹底清理回收資源
for (const track of this.activeTracks) {
track.audio.pause()
track.audio.removeAttribute('src')
track.audio.onloadedmetadata = null
track.audio.onended = null
track.trackGain.disconnect()
track.source.disconnect()
}
this.activeTracks.clear()
this.baseGainNode.disconnect()
this.globalGainNode.disconnect()
this.muteGainNode.disconnect()
}三步驟:停止排程 → 漸出音量 → 徹底清理資源。乾淨利落。( •̀ ω •́ )✧
Composable:管理播放器生命週期
最後用 useSoundscapePlayer composable 把所有播放器串起來。
content\aquarium\hexazen\domains\soundscape\player\use-soundscape-player.ts
export function useSoundscapePlayer(
blockMap: ShallowReactive<Map<string, Block>>,
options: {
muted?: Ref<boolean>;
volume?: Ref<number>;
weather?: Ref<Weather | undefined>;
} = {},
) {
const traitRegionList = computed(() => calcTraitRegionList(blockMap))
const soundscapeList = computed(() =>
resolveSoundscape({
traitRegionList: traitRegionList.value,
blockMap,
weather: weather.value,
}),
)
const activePlayerMap = shallowReactive(new Map<number, SoundscapePlayer>())
watch(soundscapeList, (newList) => {
const newIdSet = new Set(newList.map(prop('id')))
// 不在新列表中的音效 → 靜音(保留音量設定)
for (const [id, player] of activePlayerMap) {
if (!newIdSet.has(id)) {
player.muted()
}
}
// 新列表中的音效 → 建立或解除靜音
for (const scape of newList) {
if (!activePlayerMap.has(scape.id)) {
const player = new SoundscapePlayer(scape)
player.setGlobalVolume(volume.value)
player.play()
if (muted.value) {
player.muted()
}
activePlayerMap.set(scape.id, player)
}
else {
const player = activePlayerMap.get(scape.id)!
if (!muted.value) {
player.unmuted()
}
}
}
})
return { traitRegionList, soundscapeList, activePlayerMap }
}幾個值得注意的設計:
- 不在新列表中的音效是靜音而非銷毀,保留使用者調整過的音量設定
watch自動響應blockMap變化,積木一放下,音效就跟著更新- 全域音量與靜音狀態也透過
watch即時同步
總結 🐟
以上程式碼可以在此取得
- 雙軌交叉淡入淡出消除 loop 接縫感
- Interval 模式隨機播放帶來自然變化
- GainNode 四層串接實現細緻的音量控制
- 漸出動畫 + 資源清理確保優雅銷毀
- Composable 自動管理播放器生命週期
下一章讓我們來看看 3D 場景中的互動機制吧。♪( ◜ω◝و(و
TIP
因為篇幅問題,某些部分簡單帶過。
若想看到更詳細的解釋,請不吝留言或寫信給我喔!(*´∀`)~♥