React 音频显示波形与区间循环播放

quote

"There comes a time when the world gets quiet and the only thing left is your own heart. So you'd better learn the sound of it. Otherwise you'll never understand what it's saying."

Sarah Dessen, Just Listen

最近跟一位台湾的日语老师交流一番后,决定给 Saladict 增加音频控制功能,可以显示发音的波形,支持区间选择、 AB 循环和变速。这里把实现的原理以及踩过的坑分享一下。

先上 Saladict 中实现的效果:

Saladict Waveform

以及源码例子:

WaveSurfer

首先对于音频区间选择的交互,我第一反应是要做成音频处理软件那样的效果:显示波形,然后可以直接在波形上选择区间。

做 Waveform 算是小众需求,所以这方面的库不多,比较一番之后认为 WaveSurfer 这个开源项目相对靠谱,而且它可以通过插件支持区间选择。

但是它一个纯 JS 的库,结合 React 我们需要做些简单的处理,主要是组件挂载时加载以及销毁时释放。

export default class Waveform extends React.PureComponent {
  componentDidMount () {
    this.wavesurfer = WaveSurfer.create({
      container: '#waveform-container',
    })
  }

  componentWillUnmount () {
    if (this.wavesurfer) {
      this.wavesurfer.destroy()
      this.wavesurfer = null
    }
  }

  render () {
    return {
      <div id="waveform-container" />
    }
  }
}

这里面就有一个坑,Chrome 在不久前改了政策,在页面刚加载发生用户交互之前 AudioContext 是处于一个 suspended 状态,即无法进行播放,需要监听 statechange 事件在状态变成 running 之后再调用 resume() 方法恢复。

然而实测这个 statechange 事件发生的时机有点玄学,且通过 wavesurfer.backend.ac.resume() 之后不知为何还是不能加载出波形。所以最保险的方式是懒加载 WaveSurfer,在第一次播放的时候才初始化,这时几乎可以保证用户已经进行了交互。

export default class Waveform extends React.PureComponent {
  initWaveSurfer = () => {    this.wavesurfer = WaveSurfer.create({
      container: '#waveform-container',
    })
  }
  loadAudio = (src) => {    if (this.wavesurfer) {      this.reset()    } else {      this.initWaveSurfer()    }    this.wavesurfer.load(src)  }  reset = () => {    if (this.wavesurfer) {      this.wavesurfer.pause()      this.wavesurfer.empty()    }  }
  componentDidMount () {
    // 监听交互事件    // 回调 this.loadAudio(src)  }

  componentWillUnmount () {
    if (this.wavesurfer) {
      this.wavesurfer.destroy()
      this.wavesurfer = null
    }
  }

  render () {
    return {
      <div id="waveform-container" />
    }
  }
}

接下来处理播放和暂停,比较直接的方式是监听 Wavesurfer 的 playpause 再来改变组件 state,然而考虑到后面需要做其它处理,而这两个方法在我们后面特殊处理循环的时候触发频率会比较高,所以就不监听而分开处理。

export default class Waveform extends React.PureComponent {
  state = {
    isPlaying: false,
    loop: true
  }

  initWaveSurfer = () => {
    this.wavesurfer = WaveSurfer.create({
      container: '#waveform-container',
    })

    wavesurfer.on('ready', this.play)
    wavesurfer.on('finish', this.onPlayEnd)
  }

  play = () => {
    this.setState({ isPlaying: true })
    if (this.wavesurfer) {
      this.wavesurfer.play()
    }
  }

  pause = () => {
    this.setState({ isPlaying: false })
    if (this.wavesurfer) {
      this.wavesurfer.pause()
    }
  }

  togglePlay = () => {
    this.state.isPlaying ? this.pause() : this.play()
  }

  onPlayEnd = () => {
    this.state.loop ? this.play() : this.pause()
  }
  // ...
}

区间选择

接下来就是实现区间选择。Wavesurfer 提供了 Regions plugin,我们需要针对交互做些调整。

import RegionsPlugin from 'wavesurfer.js/dist/plugin/wavesurfer.regions.min.js'
export default class Waveform extends React.PureComponent {
  state = {
    isPlaying: false,
    loop: true
  }

  initWaveSurfer = () => {
    this.wavesurfer = WaveSurfer.create({
      container: '#waveform-container',
      plugins: [RegionsPlugin.create()]    })

    // 允许鼠标划选区间
    wavesurfer.enableDragSelection({})
    // 鼠标按下去那一刻触发
    wavesurfer.on('region-created', region => {
      // Region 插件支持多个区间,我们只保留最新一个
      this.removeRegion()
      this.region = region
    })
    // 鼠标放开那一刻触发
    wavesurfer.on('region-update-end', this.play)
    // 播放指示器刚出区间区域那一刻触发
    wavesurfer.on('region-out', this.onPlayEnd)

    // 鼠标点任意波形区域时触发,改变指示器位置
    wavesurfer.on('seek', () => {
      if (!this.isInRegion()) {
        // 用户点击了区间外,把区间移除掉
        this.removeRegion()
      }
    })

    wavesurfer.on('ready', this.play)
    wavesurfer.on('finish', this.onPlayEnd)
  }

  /** 检查当前指示器是否在区间中 */
  isInRegion = (region = this.region) => {
    if (region && this.wavesurfer) {
      const curTime = this.wavesurfer.getCurrentTime()
      return curTime >= region.start && curTime <= region.end
    }
    return false
  }

  removeRegion = () => {
    if (this.region) {
      this.region.remove()
    }
    this.region = null
  }

  play = () => {
    this.setState({ isPlaying: true })
    if (this.region && !this.isInRegion()) {
      // 如果指示器不在区间中则重新从区间起点播放
      this.wavesurfer.play(this.region.start)
    } else {
      // 否则继续播放
      this.wavesurfer.play()
    }
  }

  // ...
}

注意 Regions 插件中其实提供了循环播放的方法,但是为了接下来更细粒度的控制,我们改为监听指示器移出区间右侧时的 region-out 事件,然后与 finish 事件用同样的方法处理。

本文介绍了如何结合 React 和 Wavesurfer 显示音频波形,实现区间选择和循环播放。下篇文章中我将继续分享如何实现音频的加速和减速。