audio.js

'use strict'

// ******************************** 音频管理器 ********************************

const AudioManager = new class {
  /** 音频上下文对象
   *  @type {AudioContext}
   */ context

  /** BGM播放器
   *  @type {AudioPlayer}
   */ bgm

  /** BGS播放器
   *  @type {AudioPlayer}
   */ bgs

  /** CV播放器
   *  @type {AudioPlayer}
   */ cv

  /** SE播放器
   *  @type {MultipleAudioPlayer}
   */ se

  /** SE衰减距离
   *  @type {number}
   */ seAttenuationDistance = 0

  /** SE衰减过渡曲线ID
   *  @type {string}
   */ seAttenuationEasingId = ''

  /** 初始化音频管理器 */
  initialize() {
    // 创建音频上下文
    const context = new AudioContext()
    this.context = context

    // 创建播放器
    const bgm = new AudioPlayer(true)
    const bgs = new AudioPlayer(true)
    const cv = new AudioPlayer(false)
    const se = new MultipleAudioPlayer()
    this.bgm = bgm
    this.bgs = bgs
    this.cv = cv
    this.se = se
    this.seAttenuationDistance = Data.config.soundAttenuation.distance
    this.seAttenuationEasingId = Data.config.soundAttenuation.easingId

    Promise.resolve().then(() => {
      // 创建混响卷积器(比较消耗CPU,放到栈尾执行避免阻塞)
      AudioReverb.getConvolver()
    })

    // 移动设备:切出去的时候暂停播放
    if (Stats.deviceType === 'mobile') {
      document.on('visibilitychange', () => {
        if (context.state === 'running') {
          if (document.hidden) {
            bgm.pause()
            bgs.pause()
            cv.pause()
            se.pause()
          } else {
            bgm.continue()
            bgs.continue()
            cv.continue()
            se.continue()
          }
        }
      })
    }

    // Web模式:按下键盘或鼠标时恢复音频上下文
    // 如果在用户交互前创建了音频上下文
    // 默认被Chrome浏览器挂起以免骚扰用户
    if (context.state === 'suspended') {
      const resume = event => {
        if (context.state === 'suspended') {
          context.resume()
        }
      }
      const statechange = event => {
        if (context.state === 'running') {
          bgm.audio.src &&
          bgm.audio.play()
          bgs.audio.src &&
          bgs.audio.play()
          cv.audio.src &&
          cv.audio.play()
          for (const audio of se.audios) {
            audio.src &&
            audio.play()
          }
          window.off('keydown', resume, {capture: true})
          window.off('mousedown', resume, {capture: true})
          context.off('statechange', statechange)
        }
      }
      // pointerdown不能代替mousedown进行resume
      window.on('keydown', resume, {capture: true})
      window.on('mousedown', resume, {capture: true})
      context.on('statechange', statechange)
    }
  }

  /** 重置所有音频播放器 */
  reset() {
    this.bgm.reset()
    this.bgs.reset()
    this.cv.reset()
    this.se.reset()
  }
}

// ******************************** 音频播放器类 ********************************

class AudioPlayer {
  /** HTML音频元素
   *  @type {HTMLAudioElement}
   */ audio

  /** 媒体元素音频源节点
   *  @type {MediaElementAudioSourceNode}
   */ source

  /** 左右声道控制节点
   *  @type {StereoPannerNode}
   */ panner

  /** 混响(卷积器)节点
   *  @type {ConvolverNode|null}
   */ reverb

  /** 音频保存状态缓存
   *  @type {Object}
   */ cache

  /** 音频默认循环播放
   *  @type {boolean}
   */ defaultLoop

  /** 音量过渡计时器
   *  @type {Timer|null}
   */ volumeTransition

  /** 声像过渡计时器
   *  @type {Timer|null}
   */ panTransition

  /**
   * 单源音频播放器
   * @param {boolean} loop 设置默认播放循环
   */
  constructor(loop) {
    const {context} = AudioManager
    this.audio = new Audio()
    this.source = context.createMediaElementSource(this.audio)
    this.gain = context.createGain()
    this.panner = context.createStereoPanner()
    this.reverb = null
    this.cache = null
    this.volumeTransition = null
    this.panTransition = null
    this.defaultLoop = loop
    this.audio.autoplay = true
    this.audio.loop = loop
    this.audio.guid = ''

    // 连接节点
    this.source.connect(this.gain)
    this.gain.connect(this.panner)
    this.panner.connect(context.destination)
  }

  /**
   * 播放音频文件
   * @param {string} guid 音频文件ID
   * @param {number} [volume] 播放音量[0-1]
   */
  play(guid, volume = 1) {
    if (guid) {
      const audio = this.audio
      if (audio.guid !== guid ||
        audio.readyState !== 4 ||
        audio.ended === true) {
        audio.src = File.getPathByGUID(guid)
        audio.guid = guid
        audio.volume = volume
      }
    }
  }

  /** 停止播放 */
  stop() {
    const audio = this.audio
    audio.pause()
    audio.currentTime = 0
    audio.guid = ''
  }

  /** 暂停播放 */
  pause() {
    const audio = this.audio
    if (audio.duration > 0 &&
      audio.ended === false &&
      audio.paused === false) {
      audio.pause()
    }
  }

  /** 继续播放 */
  continue() {
    const audio = this.audio
    if (audio.duration > 0 &&
      audio.ended === false &&
      audio.paused === true) {
      audio.play()
    }
  }

  /** 保存当前的播放状态 */
  save() {
    const audio = this.audio
    this.cache = {
      guid: audio.guid,
      offset: audio.currentTime,
    }
  }

  /** 恢复保存的播放状态 */
  restore() {
    const cache = this.cache
    if (cache !== null) {
      const audio = this.audio
      audio.src = File.getPathByGUID(cache.guid)
      audio.guid = cache.guid
      audio.currentTime = cache.offset
      this.cache = null
    }
  }

  /**
   * 设置音量
   * @param {number} volume 播放音量[0-1]
   * @param {string} [easingId] 过渡曲线ID
   * @param {number} [duration] 持续时间(毫秒)
   */
  setVolume(volume, easingId, duration) {
    // 如果上一次的音量过渡未结束,移除
    if (this.volumeTransition !== null) {
      this.volumeTransition.remove()
      this.volumeTransition = null
    }
    const {gain} = this.gain
    if (duration > 0) {
      const start = gain.value
      const end = volume
      const easing = Easing.get(easingId)
      // 创建音量过渡计时器
      this.volumeTransition = new Timer({
        duration: duration,
        update: timer => {
          const time = easing.map(timer.elapsed / timer.duration)
          gain.value = Math.clamp(start * (1 - time) + end * time, 0, 1)
        },
        callback: () => {
          this.volumeTransition = null
        },
      }).add()
    } else {
      // 直接设置音量
      gain.value = Math.clamp(volume, 0, 1)
    }
  }

  /**
   * 设置声像(左右声道音量)
   * @param {number} pan 声像[-1~+1]
   * @param {string} [easingId] 过渡曲线ID
   * @param {number} [duration] 持续时间(毫秒)
   */
  setPan(pan, easingId, duration) {
    // 如果上一次的声像过渡未结束,移除
    if (this.panTransition !== null) {
      this.panTransition.remove()
      this.panTransition = null
    }
    const panner = this.panner.pan
    if (duration > 0) {
      const start = panner.value
      const end = pan
      const easing = Easing.get(easingId)
      // 创建声像过渡计时器
      this.panTransition = new Timer({
        duration: duration,
        update: timer => {
          const time = easing.map(timer.elapsed / timer.duration)
          panner.value = Math.clamp(start * (1 - time) + end * time, -1, 1)
        },
        callback: () => {
          this.panTransition = null
        },
      }).add()
    } else {
      // 直接设置声像
      panner.value = Math.clamp(pan, -1, 1)
    }
  }

  /**
   * 设置混响
   * @param {number} dry 干声增益[0-1]
   * @param {number} wet 湿声增益[0-1]
   * @param {string} [easingId] 过渡曲线ID
   * @param {number} [duration] 持续时间(毫秒)
   */
  setReverb(dry, wet, easingId, duration) {
    if (this.reverb === null && !(
      dry === 1 && wet === 0)) {
      // 满足条件时创建混响管理器
      new AudioReverb(this)
    }
    if (this.reverb !== null) {
      // 设置混响参数(混响管理器可能被删除)
      this.reverb.set(dry, wet, easingId, duration)
    }
  }

  /**
   * 设置循环
   * @param {boolean} loop 循环播放
   */
  setLoop(loop) {
    this.audio.loop = loop
  }

  /** 重置音频播放器 */
  reset() {
    this.stop()
    this.setVolume(1)
    this.setPan(0)
    this.setReverb(1, 0, '', 0)
    this.setLoop(this.defaultLoop)
    this.cache = null
  }
}

// ******************************** 多源音频播放器类 ********************************

class MultipleAudioPlayer {
  /**
   * 备用的音频元素池
   * @type {Array<HTMLAudioElement>}
   */ audioPool

  /**
   * 正在播放的音频元素列表
   * @type {Array<HTMLAudioElement>}
   */ audios

  /** 左右声道控制节点
   *  @type {StereoPannerNode}
   */ panner

  /** 混响(卷积器)节点
   *  @type {ConvolverNode|null}
   */ reverb

  /** 音量过渡计时器
   *  @type {Timer|null}
   */ volumeTransition

  /** 声像过渡计时器
   *  @type {Timer|null}
   */ panTransition

  /** 多源音频播放器 */
  constructor() {
    const {context} = AudioManager
    this.audioPool = []
    this.audios = []
    this.gain = context.createGain()
    this.panner = context.createStereoPanner()
    this.reverb = null
    this.volumeTransition = null
    this.panTransition = null

    // 连接节点
    this.gain.connect(this.panner)
    this.panner.connect(context.destination)
  }

  /** 获取音频元素 */
  getAudio() {
    let audio = this.audioPool.pop()
    if (audio === undefined) {
      audio = new Audio()
      const source = AudioManager.context.createMediaElementSource(audio)
      const onStop = () => {
        if (this.audios.remove(audio)) {
          this.audioPool.push(audio)
          source.disconnect(this.gain)
        }
      }
      audio.onStop = onStop
      audio.autoplay = true
      audio.source = source
      audio.on('ended', onStop)
      audio.on('error', onStop)
    }
    this.audios.push(audio)
    audio.source.connect(this.gain)
    return audio
  }

  /**
   * 获取不久前的音频元素
   * @param {string} guid 音频文件ID
   * @returns {audio|undefined}
   */
  getRecentlyAudio(guid) {
    for (const audio of this.audios) {
      if (audio.guid === guid && audio.currentTime < 0.05) {
        return audio
      }
    }
    return undefined
  }

  /**
   * 播放音频文件
   * @param {string} guid 音频文件ID
   * @param {number} [volume] 播放音量[0-1]
   * @param {number} [playbackRate] 播放速度
   */
  play(guid, volume = 1, playbackRate = 1) {
    if (guid) {
      const audio = this.getRecentlyAudio(guid)
      if (audio) {
        audio.volume = Math.max(audio.volume, volume)
      } else {
        const audio = this.getAudio()
        audio.guid = guid
        audio.src = File.getPathByGUID(guid)
        audio.volume = volume
        audio.playbackRate = playbackRate
      }
    }
  }

  /**
   * 播放音频文件(距离衰减)
   * @param {string} guid 音频文件ID
   * @param {Object} location 具有场景坐标的对象
   * @param {number} [volume] 播放音量[0-1]
   * @param {number} [playbackRate] 播放速度
   */
   playAt(guid, location, volume = 1, playbackRate = 1) {
    if (guid) {
      const dist = Math.dist(Camera.x, Camera.y, location.x, location.y)
      if (dist < AudioManager.seAttenuationDistance) {
        const easing = Easing.get(AudioManager.seAttenuationEasingId)
        const attenuation = easing.map(dist / AudioManager.seAttenuationDistance)
        const finalVolume = volume * (1 - attenuation)
        this.play(guid, finalVolume, playbackRate)
      }
    }
  }

  /** 停止播放 */
  stop() {
    const {audios} = this
    let i = audios.length
    while (--i >= 0) {
      audios[i].src = ''
      audios[i].onStop()
    }
  }

  /** 暂停播放 */
  pause() {
    for (const audio of this.audios) {
      if (audio.ended === false &&
        audio.paused === false) {
        audio.pause()
      }
    }
  }

  /** 继续播放 */
  continue() {
    for (const audio of this.audios) {
      if (audio.ended === false &&
        audio.paused === true) {
        audio.play()
      }
    }
  }

  /**
   * 设置音量
   * @param {number} volume 播放音量[0-1]
   * @param {string} [easingId] 过渡曲线ID
   * @param {number} [duration] 持续时间(毫秒)
   */
  setVolume(volume, easingId, duration) {
    // 如果上一次的音量过渡未结束,移除
    if (this.volumeTransition !== null) {
      this.volumeTransition.remove()
      this.volumeTransition = null
    }
    const {gain} = this.gain
    if (duration > 0) {
      const start = gain.value
      const end = volume
      const easing = Easing.get(easingId)
      // 创建音量过渡计时器
      this.volumeTransition = new Timer({
        duration: duration,
        update: timer => {
          const time = easing.map(timer.elapsed / timer.duration)
          gain.value = Math.clamp(start * (1 - time) + end * time, 0, 1)
        },
        callback: () => {
          this.volumeTransition = null
        },
      }).add()
    } else {
      // 直接设置音量
      gain.value = Math.clamp(volume, 0, 1)
    }
  }

  /**
   * 设置声像(左右声道音量)
   * @param {number} pan 声像[-1~+1]
   * @param {string} [easingId] 过渡曲线ID
   * @param {number} [duration] 持续时间(毫秒)
   */
  setPan(pan, easingId, duration) {
    if (this.panTransition !== null) {
      this.panTransition.remove()
      this.panTransition = null
    }
    const panner = this.panner.pan
    if (duration > 0) {
      const start = panner.value
      const end = pan
      const easing = Easing.get(easingId)
      this.panTransition = new Timer({
        duration: duration,
        update: timer => {
          const time = easing.map(timer.elapsed / timer.duration)
          panner.value = Math.clamp(start * (1 - time) + end * time, -1, 1)
        },
        callback: () => {
          this.panTransition = null
        },
      }).add()
    } else {
      panner.value = Math.clamp(pan, -1, 1)
    }
  }

  /**
   * 设置混响
   * @param {number} dry 干声增益[0-1]
   * @param {number} wet 湿声增益[0-1]
   * @param {string} [easingId] 过渡曲线ID
   * @param {number} [duration] 持续时间(毫秒)
   */
  setReverb(dry, wet, easingId, duration) {
    if (this.reverb === null && !(
      dry === 1 && wet === 0)) {
      new AudioReverb(this)
    }
    if (this.reverb !== null) {
      this.reverb.set(dry, wet, easingId, duration)
    }
  }

  /**
   * 设置循环
   * @param {boolean} loop 循环播放
   */
  setLoop(loop) {
    for (const audio of this.audios) {
      audio.loop = loop
    }
  }

  /** 重置音频播放器 */
  reset() {
    this.stop()
    this.setVolume(1)
    this.setPan(0)
    this.setReverb(1, 0, '', 0)
    this.setLoop(false)
  }
}

// ******************************** 音频混响类 ********************************

class AudioReverb {
  player      //:object
  input       //:object
  output      //:object
  dryGain     //:object
  wetGain     //:object
  convolver   //:object
  dry         //:number
  wet         //:number
  transition  //:object

  /**
   * 音频混响
   * @param {AudioPlayer|MultipleAudioPlayer} player 音频播放器实例
   */
  constructor(player) {
    const {context} = AudioManager
    this.player = player
    this.input = player.panner
    this.output = context.destination
    this.dryGain = context.createGain()
    this.wetGain = context.createGain()
    this.convolver = AudioReverb.getConvolver()
    this.dry = -1
    this.wet = -1
    this.transition = null

    // 连接节点
    this.connect()
  }

  /** 连接节点 */
  connect() {
    this.player.reverb = this
    this.input.disconnect(this.output)
    this.input.connect(this.dryGain)
    this.dryGain.connect(this.output)
    this.input.connect(this.wetGain)
    this.wetGain.connect(this.convolver)
  }

  /** 断开节点 */
  disconnect() {
    this.player.reverb = null
    this.input.disconnect(this.dryGain)
    this.dryGain.disconnect(this.output)
    this.input.disconnect(this.wetGain)
    this.wetGain.disconnect(this.convolver)
    this.input.connect(this.output)
  }

  /**
   * 设置混响参数
   * @param {number} dry 干声增益[0-1]
   * @param {number} wet 湿声增益[0-1]
   * @param {string} easingId 过渡曲线
   * @param {number} duration 持续时间(毫秒)
   */
  set(dry, wet, easingId, duration) {
    // 如果上一次的混响过渡未结束,移除
    if (this.transition !== null) {
      this.transition.remove()
      this.transition = null
    }
    if (duration > 0) {
      if (this.dry === null) {
        this.setDry(1)
        this.setWet(0)
      }
      const startDry = this.dry
      const startWet = this.wet
      const easing = Easing.get(easingId)
      // 创建混响过渡计时器
      this.transition = new Timer({
        duration: duration,
        update: timer => {
          const time = easing.map(timer.elapsed / timer.duration)
          this.setDry(startDry * (1 - time) + dry * time)
          this.setWet(startWet * (1 - time) + wet * time)
        },
        callback: () => {
          this.transition = null
          if (dry === 1 && wet === 0) {
            this.disconnect()
          }
        },
      }).add()
    } else {
      // 直接设置混响
      this.setDry(dry)
      this.setWet(wet)
      // 如果没有混响,断开连接
      if (dry === 1 && wet === 0) {
        this.disconnect()
      }
    }
  }

  /**
   * 设置干声
   * @param {number} dry 干声增益[0-1]
   */
  setDry(dry) {
    if (this.dry !== dry) {
      this.dry = dry
      this.dryGain.gain.value = dry
    }
  }

  /**
   * 设置湿声
   * @param {number} wet 湿声增益[0-1]
   */
  setWet(wet) {
    if (this.wet !== wet) {
      this.wet = wet
      this.wetGain.gain.value = wet * 2
    }
  }

  /**
   * 获取卷积器
   * @returns {ConvolverNode}
   */
  static getConvolver() {
    if (!AudioReverb.convolver) {
      const PREDELAY = 0.1
      const DECAYTIME = 2
      const context = AudioManager.context
      const duration = PREDELAY + DECAYTIME
      const sampleRate = context.sampleRate
      const sampleCount = Math.round(sampleRate * duration)
      const convolver = context.createConvolver()
      const filter = context.createBiquadFilter()
      const buffer = context.createBuffer(2, sampleCount, sampleRate)
      const bufferLength = buffer.length
      const delayLength = Math.round(bufferLength * PREDELAY / duration)
      const decayLength = Math.round(bufferLength * DECAYTIME / duration)
      const random = Math.random
      for (let i = 0; i < buffer.numberOfChannels; i++) {
        const samples = buffer.getChannelData(i)
        for (let i = 0; i < delayLength; i++) {
          samples[i] = (random() * 2 - 1) * i / delayLength
        }
        for (let i = delayLength; i < bufferLength; i++) {
          const time = (bufferLength - i) / decayLength
          samples[i] = (random() * 2 - 1) * time
        }
      }
      convolver.buffer = buffer
      filter.type = 'lowpass'
      filter.frequency.value = 3000
      convolver.connect(filter)
      filter.connect(context.destination)
      AudioReverb.convolver = convolver
    }
    return AudioReverb.convolver
  }

  // 共享卷积器
  static convolver
}