Audio实现音频文本同步


最近公司项目接到一个需求,要实现录音播放时和文本同步,类似于音乐播放器播放歌曲时,歌词和声音同步的效果。要实现这个功能需要监听audio标签的实时播放时间,然后在监听事件中处理文本,通过当前时间定位到对应的文本数据,并展示在页面。

原生实现

使用原生的audio标签时,需要获取audio标签dom对象,并给它添加timeupdate事件,在事件中就可以实时处理文本了,代码步骤如下

// audio标签
<audio src="test.mp3" id="myAudio" />

// 1.获取 aduio 对象
var audio = document.querySelector("#myAudio");

// 2.注册事件
audio.addEventListener("timeupdate",function(){
    // 当前播放时间,单位秒
    var nowTime = audio.currentTime;
    // ...其他处理
});

在事件中获取当前时间,然后定位到文本数据,再把文本颜色设置为红色,这样就实现了一个简单的播放器音频和文本同步的效果,最终效果图如下

React中实现

上面所说的是原生实现,但目前主流的一般会使用React或者Vue,当然也可以在React或Vue组件中操作Dom,但这个不符合框架的设计思想。另外还有一个问题,歌词很长的情况下,播放到滚动条下面时,文本会被遮住,这就要求当播放到滚动条下面,歌词被遮住时,滚动条需要自动往下滚动。

滚动条自动下滑

要实现滚动条自动下滑,有三个步骤

  1. 获取当前文本标签的Dom对象,相对于父容器(有滚动条的容器)顶部的距离:sTopHeight
  2. 获取父容器上方被滚动条遮住的高度:zHeight
  3. 获取父容器的可视高度,这个高度一般设置是固定高度:xHeight。计算公式当 sTopHeight > zHeight + xHeight 为真时,说明文本已经超出可视高度,被遮住了,这时就需要向下滑动滚动条了

以下是实现代码,这时React中直接操作Dom方式的实现

constructor(props){
    super(props);
    this.state = {
      txtList:[
        {
          start:24,
          time:"00:24",
          txt:"月色烙印在城墙"
        },{
          start:26,
          time:"00:26",
          txt:"风声呼啸过苍莽"
        },{
          start:29,
          time:"00:29",
          txt:"有双眸如炬般明亮"
        },{
          start:35,
          time:"00:35",
          txt:"将生死握于手掌"
        },{
          start:38,
          time:"00:38",
          txt:"待尘沙磨砺翅膀"
        },{
          start:41,
          time:"00:41",
          txt:"褪变出崭新模样"
        },{
          start:46,
          time:"00:46",
          txt:"那团火在何处绽放"
        },{
          start:49,
          time:"00:49",
          txt:"开始侵袭六腑五脏"
        },{
          start:51,
          time:"00:51",
          txt:"却使我全身的血脉都膨胀"
        },{
          start:57,
          time:"00:57",
          txt:"有太多的相识背叛"
        },{
          start:60,
          time:"01:00",
          txt:"不必谁原谅"
        },{
          start:63,
          time:"01:03",
          txt:"只恨这英雄年少不轻狂"
        },{
          start:68,
          time:"00:26",
          txt:"我乘风而下"
        },{
          start:70,
          time:"01:10",
          txt:"要在破晓处羽化"
        },{
          start:73,
          time:"01:13",
          txt:"要让满座的喧哗 屏息惊诧"
        },{
          start:80,
          time:"01:20",
          txt:"凭誓约回答"
        },{
          start:82,
          time:"01:26",
          txt:"两人的命运交叉"
        },{
          start:85,
          time:"01:25",
          txt:"跃再多悬壁陡崖"
        },{
          start:93,
          time:"01:33",
          txt:"我不怕"
        }
      ]
    }
}

componentDidMount(){
    const audio = document.querySelector(".myAudio>audio");
    const list = this.state.txtList;
    const txtContain = document.querySelector('#txtContain');

    audio.addEventListener("timeupdate",() => {
      for(let i=0;i<list.length;i++){
        let item = list[i];
        item.chose='N';
      }
      for(let i=0;i<list.length;i++){
        let item = list[i];
        if(item.start === parseInt(audio.currentTime)){
          item.chose='Y';
          let txtDiv = document.querySelector('#txtContain > #txt'+item.start);
          //可见窗体高度的一半(歌词正好在中间)+被覆盖的高度
          let zHeight= parseInt(150+txtContain.scrollTop);
          // 如果相对父容器偏移量大于(可见窗体高度+被覆盖的高度),则滚动条下移100px
          if(txtDiv.offsetTop>zHeight){
            //this.myScrollTop(txtContain,22,220);
            txtContain.scrollTop=txtContain.scrollTop+22;
          }
          this.setState({txtList:list});
          break;
        }
      }
    });
    const txtContains = document.querySelector('#txtContains');
    this.setState({txtContains:txtContains});
  }



render() {

    const choseStyle={
      color:'red',
      height:'22px'
    };

    const noStyle={
      color:'black',
      height:'22px'
    };

    return (
      <PageHeaderWrapper>
        <div style={{marginTop:'30px',width:'600px',display:'inline-block'}}>
          <div className="site-card-border-less-wrapper">
            <Card title={this.state.fieldId} bordered={false} style={{ width: 500 }}>
              <div> <AudioPlayer src={url} className="myAudio"/></div>
            </Card>
          </div>
          <Card size="small" title="歌词" style={{width:'500px'}}>
            <div style={{height:'250px',overflow:'auto'}} id="txtContain">
              {this.state.txtList.map( (item) =>
                  <div key={item.start} style={item.chose === 'Y' ? choseStyle:noStyle} 
                    id={'txt'+item.start}>
                    [{item.time}]:{item.txt}
                  </div>
              )}
            </div>
          </Card>
        </div>
      </PageHeaderWrapper>
    );
  }

最终效果如下

效果美化

在React中直接操作Dom虽然可以实现效果,但看起来都不怎么优雅。由于我使用了React的组件react-h5-audio-player,这个组件提供了timeupdate事件,所以优化以下代码,顺便把效果美化以下,播放行时加粗文本字体和行的阴影效果,哈哈。


  audioTimeUpdate = (currentTime) => {
    let list = this.state.txtLists;
    let txtContain = this.state.txtContains;
    for(let i=0;i<list.length;i++){
      let item = list[i];
      item.chose='N';
    }
    for(let i=0;i<list.length;i++){
      let item = list[i];
      if(item.start === parseInt(currentTime)){
        item.chose='Y';
        let txtDiv = document.querySelector('#txtContains > #txt'+item.start);
        //可见窗体高度的一半(歌词正好在中间)+被覆盖的高度
        let zHeight= parseInt(150+txtContain.scrollTop);
        // 如果相对父容器偏移量大于(可见窗体高度+被覆盖的高度),则滚动条下移100px
        if(txtDiv.offsetTop>zHeight){
          //this.myScrollTop(txtContain,22,220);
          txtContain.scrollTop=txtContain.scrollTop+22;
        }
        this.setState({txtLists:list});
        break;
      }
    }
  };

  render() {

    const choseStyles={
      boxShadow:'0 0 50px rgb(166,124,64)',
      height:'22px',
      borderRadius:'8px',
      paddingLeft:'5px',
      fontWeight:600
    };

    const noStyles={
      height:'22px'
    };
    return (
          <PageHeaderWrapper>

            <div style={{marginTop:'30px',width:'600px',display:'inline-block'}}>
              <div className="site-card-border-less-wrapper">
                <Card title={this.state.fieldId} bordered={false} style={{ width: 500 }}>
                  <div> <AudioPlayer src={url} onListen={this.audioTimeUpdate}/></div>
                </Card>
              </div>
              <Card size="small" title="歌词" style={{width:'500px'}}>
                <div style={{height:'250px',overflow:'auto'}} id="txtContains">
                  {this.state.txtLists.map( (item) =>
                    <div key={item.start} style={item.chose === 'Y' ? choseStyles:noStyles} 
                        id={'txt'+item.start}>
                      [{item.time}]:{item.txt}
                    </div>
                  )}
                </div>
              </Card>
            </div>
          </PageHeaderWrapper>
        );
    }

最终效果如下

总结

在开发过程中遇到的问题,当我通过元素的offsetTop属性获取元素相对于父容器的距离时,会出现距离不符合预期的情况,这实际上是offsetTop定位机制的问题,与它类似的属性还有offsetLeft,需要注意的是offsetTopoffsetLeft定位机制是相对于最近的祖先定位元素(即父元素的 position 属性被设置为 relativeabsolutefixed的元素)的向上或向左偏移值。如果父元素的 position 属性非 relativeabsolutefixed,则都是相对于body定位。


Author: 顺坚
Reprint policy: All articles in this blog are used except for special statements CC BY 4.0 reprint polocy. If reproduced, please indicate source 顺坚 !
评论
 Previous
Android与Linux的区别 Android与Linux的区别
Android这一词最先出现在法国作家利尔亚当在1886年发表的科幻小说《未来夏娃》中,作者将外表像人类的机器起名为Android,这也就是Android小人名字的由来。Android是基于Linux系统的开源操作系统,是由Andy Rub
2020-06-21
Next 
React中使用Dva React中使用Dva
在公司使用Antd时接触到了dva,它是由阿里架构师 sorrycc 带领 team 完成的一套前端框架,在作者的 github 里是这么描述它的:“dva 是 react 和 redux 的最佳实践”。dva 是一个基于 redux 和
2020-06-14
  TOC