---
url: /column-hexazen/03-soundscape-player.md
description: 怎麼讓白噪音不再重複？雙軌交叉淡入淡出加上隨機播放，讓耳朵不再疲勞。♪( ◜ω◝و(و
---

![hexazen-cover](/hexazen-cover.webp){.cover}

# HexaZen - EP03：音效播放引擎

上一章我們設計了聲景規則系統，知道什麼時候該播放什麼音效，但是「播放音效」不是只有播放就好，有些小細節需要注意。%ԅ(´∀\` ԅ)%

例如 loop 音效循環到起點時，會有明顯的「接縫感」，這章就來解決這個問題！%◝( •ω• )◟
%

## 型別定義

先來看看 Soundscape 的資料結構。

`content\aquarium\hexazen\domains\soundscape\type.ts`

```typescript
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，控制靜音                     |

```typescript
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 軌，兩軌重疊的期間自然過渡，就聽不到接縫了。

```typescript
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` 中隨機挑一個音效播放，播完後等待隨機秒數再播下一個。

```typescript
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 秒的漸出：

```typescript
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`

```typescript
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` 即時同步

## 總結 🐟

以上程式碼可以[在此取得](https://github.com/Codfisher/cod-aquarium/tree/main/content/aquarium/hexazen/domains/soundscape)

* 雙軌交叉淡入淡出消除 loop 接縫感
* Interval 模式隨機播放帶來自然變化
* GainNode 四層串接實現細緻的音量控制
* 漸出動畫 + 資源清理確保優雅銷毀
* Composable 自動管理播放器生命週期

下一章讓我們來看看 3D 場景中的互動機制吧。%♪( ◜ω◝و(و%

::: tip
因為篇幅問題，某些部分簡單帶過。

若想看到更詳細的解釋，請不吝留言或寫信給我喔！%(\*´∀\`)~♥%
:::
