Skip to content

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

總結 🐟

以上程式碼可以在此取得

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

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

TIP

因為篇幅問題,某些部分簡單帶過。

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