前端如何实现音频片段的的无误差毫秒级精准播放

概述

本文会讲述 Web Audio API 以及音频剪切等几种实现精准控制音频播放的方案,同时讲述了一些前端二进制、音频以及 node 服务端的知识。

背景

产品要实现上传视频自动生成字幕,可以调整每条字幕的打轴时间,具体产品效果类似下图火山视频翻译

我们调整前端的字幕时间轴后,可以对单条的字幕进行播放试听以验证调整的是否准确,我们采用的方案是让视频进度跳转至指定开始时间播放,然后轮询至结束时间停止。但是发现在播放视频的指定时间段时,无法在指定时间段的结束时间精准停止播放。

实现方案

要播放指定的时间范围,那么有两个时间点我们需要关注:

  • 开始时间:开始时间很简单,播放前先改变一下 audio 起始播放时间就可以
  • 结束时间:误差都是出现在结束时间的控制上,所以我们要找准确控制播放的核心点,就在于找到一个合适的时机控制播放结束

使用 audio 标签播放

在 web 端播放视频,我们很容易联想到使用 audio 标签,开始播放时,只需要先改变 audio.currentTime,然后再执行 play() 方法就可以:

typescript 复制代码
import React, { useState, useRef } from 'react';
import { Button } from '@arco-design/web-react';

export default function UseAudio() {
  const audioRef = useRef<HTMLAudioElement>(null);
  const [startTime, setStartTime] = useState<number>(10); // 开始播放时间
  const [endTime, setEndTime] = useState<number>(20); // 结束播放时间
  
  // 开始播放音频
  const handleClickPlay = () => {
    (audioRef.current as HTMLAudioElement).currentTime = startTime;
    audioRef.current?.play();
  };
  
  return (
    <div>
      <audio
        ref={audioRef}
        src="https://xxx.xx/audio"
        controls={true}
      ></audio>
      <Button type="primary" onClick={handleClickPlay}>
        播放
      </Button>
    </div>
  );
}

那么结束播放时间我们如何处理呢?

方案一:onTimeUpdate(最初版)

audio 不支持直接传入结束时间,但它提供 onTimeUpdate 方法去监听事件的改变,我们可以监听到 audio.currentTime 大于 endTime 时,就停止播放:

javascript 复制代码
// ...
export default function UseAudio() {
  // ...
  
  // 监听 timeUpdate 到达结束时间时停止播放
  const handleTimeUpdate: React.ReactEventHandler<HTMLAudioElement> = () => {
    console.log((audioRef.current as HTMLAudioElement).currentTime);
    if ((audioRef.current as HTMLAudioElement).currentTime > endTime) {
      audioRef.current?.pause();
    }
  };
  
  return (
    <div>
      <audio
        ref={audioRef}
        src="https://xxx.xx/audio"
        onTimeUpdate={handleTimeUpdate}
        controls={true}
      ></audio>
      <Button type="primary" onClick={handleClickPlay}>
        播放
      </Button>
    </div>
  );
}

我们在 handleTimeUpdate 方法中打印了 audio.currentTime,这样我们可以通过控制台来观察 onTimeUpdate 的执行频率是多少:

通过控制台打印的数据我们可以观察到,打印的前后时间大概在 250ms 左右,这说明在极差的 case 下,音频的播放误差也会达到 250ms 甚至更多。

显然这个误差完全不能满足我们高精准的需求,也因此这一版的音频播放屡遭诟病,我们不得不对此进行升级。

方案二:setInterval(升级版)

既然 onTimeUpdate 的执行间隔太长,顺着这个思路我们可以想到换一个执行间隔较短的方法去监听 ------ 定时器。

我们通过一个 setInterval 去监听事件的改变:

javascript 复制代码
// interval 监听更新
  useEffect(() => {
    const timer = setInterval(() => {
      if ((audioRef.current as HTMLAudioElement).paused || (audioRef.current as HTMLAudioElement).ended) {
        // 非播放状态下直接返回
        return;
      }
      console.log((audioRef.current as HTMLAudioElement).currentTime);
      if ((audioRef.current as HTMLAudioElement).currentTime > endTime) {
        audioRef.current?.pause();
      }
    }, 4);
    return () => {
      clearInterval(timer);
    };
  }, []);

从控制台的打印结果来看,setInterval 的执行间隔已经很短了,可以达到 4ms 左右,这个误差人耳几乎难以分别了:

但是我们可以看到,在部分极差 case 下,它的误差会达到 20ms,而且上图是我在一个很简单的 demo 页面打印结果,如果随着页面逻辑的增加和性能的降低,这个误差会进一步扩大,甚至到达几百毫秒。

而 bytelingo 后台的字幕编辑页面代码多达近万行,逻辑复杂,在2小时以上的音视频下,setInterval 的执行间隔经常会在几十毫秒,这个误差依然不能满足我们的要求,需要再进一步优化。

使用 Web Audio API 播放

前置知识 ------ Web Audio API

Web Audio API 提供了在 Web 上控制音频的一个非常有效通用的系统,mdn 上对其有这样一段描述:

使用这个 API,时间可以被非常精确地控制,几乎没有延迟,这样开发人员可以准确地响应事件,并且可以针对采样数据进行编程,甚至是较高的采样率。

基于这个特性,我们可以尝试使用 Web Audio API 来监听音频的播放进程,播放到结束时间点时来结束播放。首先我们来了解一下一些常用的 Web Audio API 作为前置知识,以便于更好地理解本文后续的内容。

AudioContext 和 AudioNode

AudioContext 可以创建一个音频上下文 ,它控制其内部节点的创建、音频处理或解码的执行 。其内部包含一系列的音频节点 AudioNode ,音频节点通过它们的输入输出相互连接,形成一个链或者一个简单的网。如下图所示:

Inputs、Effects、Destination 都属于 AudioNode,Inputs 往往是一个或者多个音频源(包括 AudioBufferSourceNodeMediaElementAudioSourceNodeAudioDestinationNode 等类型),中间的 Effects 可以是中间处理模块 BiquadFilterNode 或者音量控制器如 GainNode 等等,最终声音处理完成之后连接到一个目的地AudioContext.destination,这个目的地负责把声音数据传输给扬声器或者耳机。

Web Audio Api 介绍(一)

AudioContext 上面有一系列的属性和方法,我们简单介绍一些较为常用的:

  • AudioContext.destination:返回一个AudioDestinationNode表示 context 中所有音频(节点)的最终目标节点,一般是音频渲染设备,例如扬声器。
ini 复制代码
// 创建一个音频上下文
const audioCtx = new AudioContext();

// 获取 audio
const audio = document.querySelector('#myAudio');

// 创建 MediaElementAudioSourceNode
const source = audioCtx.createMediaElementSource(audio);

// 链接到 destination
source.connect(audioCtx.destination);
  • AudioContext.createScriptProcessor():方法创建一个ScriptProcessorNode 用于通过 JavaScript 直接处理音频,可以为其创建好的对象绑定 audioprocess 事件,用于监听播放过程。它接收三个参数:

    • bufferSize::缓冲区大小,以样本帧为单位,必须是下面这些值当中的某一个:256, 512, 1024, 2048, 4096, 8192, 16384,如果不传,或者参数为 0,则取当前环境最合适的缓冲区大小。该取值控制着 audioprocess 事件被分派的频率,以及每一次调用多少样本帧被处理,值越小触发频率越高。
    • numberOfInputChannels:值为整数,用于指定输入 node 的声道的数量,默认值是 2,最高能取 32
    • numberOfOutputChannels:值为整数,用于指定输出 node 的声道的数量,默认值是 2,最高能取 32.
ini 复制代码
// 创建 ScriptProcessorNode
const processor = audioCtx.createScriptProcessor(256);
// 链接到 destination
processor.connect(audioCtx.destination);
// 监听 audioProcess
processor.addEventListener('audioprocess', e => console.log(audio.currentTime));

方案三:Web Audio 监听播放(当前采用版)

我们在前面方案的基础上,通过 Web Audio API 控制 <audio> 的播放,并通过 ScriptProcessorNode 来监听播放进度控制播放结束:

typescript 复制代码
import React, { useState, useEffect, useRef } from 'react';
import { Button } from '@arco-design/web-react';

export default function UseAudio() {
  const audioRef = useRef<HTMLAudioElement>(null);
  const [startTime, setStartTime] = useState<number>(10); // 开始播放时间
  const [endTime, setEndTime] = useState<number>(20); // 结束播放时间
  
  useEffect(() => {
    const audioCtx = new AudioContext();
    const source = audioCtx.createMediaElementSource(audioRef.current);
    // 链接到 destination
    source.connect(audioCtx.destination);
    // 创建 ScriptProcessorNode
    const processor = audioCtx.createScriptProcessor(256);
    // 链接到 destination
    processor.connect(audioCtx.destination);
    // 监听 audioProcess
    processor.addEventListener('audioprocess', handleAudioProcess);
    
    return () => {
      processor.removeEventListener('audioprocess', handleAudioProcess);
    }
  }, [])
  
  // 监听播放事件
  const handleAudioProcess = () => {
    console.log('media current:', audioRef.current.currentTime);
    if (audioRef.current.currentTime > endTime) {
      audioRef.current.pause();
    }
  }
  
  // 开始播放音频
  const handleClickPlay = () => {
    (audioRef.current as HTMLAudioElement).currentTime = startTime;
    audioRef.current?.play();
  };
  
  return (
    <div>
      <audio
        ref={audioRef}
        src="https://xxx.xx/audio"
        controls={true}
      ></audio>
      <Button type="primary" onClick={handleClickPlay}>
        播放
      </Button>
    </div>
  );
}

相比于方案二,现在我们通过 Web Audio API 创建了一个音频上下文来关联 <audio,并将 setInterval 替换成了 audioprocess 来监听音频的播放进度。我们看一下控制台的打印结果:

可以看得出始终 audioprocess 的间隔始终稳定维持在 5ms 左右,不会出现大偏差的情况,这是因为 Web Audio API 内部方法的执行是一个独立的线程,所以 audioprocess 不会因为主线程的逻辑复杂而被影响到执行频率。在我们 ByteLingo 实际的后台应用中表现也始终维持在 5ms 左右,这个误差人耳基本难以分辨,是可以满足我们的精确度要求的,目前所采用的方案也是这种。

延伸思考方案

方案三中 5ms 左右的误差,已经可以满足我们的需求了,那么我们到底有没有办法,可以做到进一步完全无误差播放呢?

我们现在的误差,是因为监听播放进度的轮询事件间隔导致的,假如我们想要播到 20.000 秒结束,如果有一个音频,它的结束时间就是 20.000 秒,那么它播完即止,不需要去监听播放进度,也不会存在播放误差了。恰巧 Web Audio API 为我们提供了音频剪辑的基础能力。

前置知识

前端二进制

要对音频文件进行剪辑,相当于进行一些二进制操作,先看一下前端二进制相关的数据格式以及文件之间的关系转换图:

暂时无法在飞书文档外展示此内容

  • 文件:文件包括音视频/图片以及 txt 等各种文件,我们浏览器访问文件有两种方式,本地上传和网络请求,网络请求(fetch/xhr)后拿到的数据。

    • 网络请求:以 fetch 为例,请求文件地址后会返回一个包含 Response 对象的 Promise 对象,我们可以调用 Response 的相关方法,例如 arrayBuffer()blob()text()json() 等将文件转化成对应格式的二进制数据
    • 本地上传:本地文件可以通过 input[type=file] 来上传文件,获得一个 File 对象,它是 Blob 对象的一个子类,可以通过 FileReader 或者 Response 获取文件内容。
  • ArrayBuffer: 代表二进制数据结构,「并且只读」 ,需要转化为 TypedArray 进行写操作,可以通过 new TypeArray(buffer) 来转换成二进制数据结构。
  • TypedArray: 是 ES6+ 新增的描述二进制数据的类数组数据结构,包括各种类型,如Uint8ArrayUintInt8ArrayUint16ArrayInt16ArrayFloat32Array 等。
Web Audio Api 介绍(二)

再来介绍一些剪切音频要用到的 Web Audio API :

  • AudioContext.sampleRate:音频采样率,表示单位时间对声音采样的多少,采样越多声音越真实。

方案四:音频剪切(延伸方案)

有了上面的基础知识,我们就可以实现下面精准无误差的播放:

typescript 复制代码
import React, { useState, useEffect, useRef } from 'react';
import { Button } from '@arco-design/web-react';

let audioCtx;
let entireAudioArray; // 整个 audioBufferArray
let channels; // 声道数量
let rate; // 采样率

export default function UseAudio() {
  const audioRef = useRef<HTMLAudioElement>(null);
  const [startTime, setStartTime] = useState<number>(10); // 开始播放时间
  const [endTime, setEndTime] = useState<number>(20); // 结束播放时间
  
  useEffect(() => {
    init();
  }, [])
  
  // 初始化
  const init = async () => {
    audioCtx = new AudioContext();
    
    const buffer = await this.getAudioArrayBuffer('https://xxx.xx/audio');
    entireAudioArray = await this.getAudioBuffer(buffer);
    
    // 获取音频通道数量
    channels = entireAudioArray.numberOfChannels;
    // 获取采样率
    rate = entireAudioArray.sampleRate;
    
    // 创建 ScriptProcessorNode
    const processor = audioCtx.createScriptProcessor(256);
    // 链接到 destination
    processor.connect(audioCtx.destination);
    // 监听 audioProcess
    processor.addEventListener('audioprocess', handleAudioProcess);
    
    return () => {
      processor.removeEventListener('audioprocess', handleAudioProcess);
    }
  }
  
  // 获取 audio url 的 arrayBuffer 数据
  const getAudioArrayBuffer = (url) => {
    return new Promise((resolve, reject) => {
      fetch(url).then(data => {
        resolve(data.arrayBuffer());
      });
    });
  }
  
  // 将 arrayBuffer 转换成 audioBuffer
  const getAudioBuffer = (buffer) => {
    return new Promise(resolve => {
      audioCtx.decodeAudioData(buffer).then(audioBuffer => {
        resolve(audioBuffer);
      });
    });
  }
  
  // 裁剪音频
  const cilpAudio = (startTime: number, endTime: number) => {
    return new Promise((res, rej) => {
      // 计算截取后需要的采样数量
      const sampleRequired = rate * (endTime - startTime);

      // 创建新的audioBuffer数据
      const newAudioBuffer = audioCtx.createBuffer(channels, sampleRequired, rate);

      // 创建Float32的空间,作为copy数据的载体
      const source = new Float32Array(sampleRequired);

      // 遍历通道,将每个通道的数据分别copy到对应的newAudioBuffer的通道
      for (let channel = 0; channel < channels; channel++) {
        this.audioBuffer.copyFromChannel(source, channel, rate * startTime);
        newAudioBuffer.copyToChannel(source, channel, 0);
      }

      // 完成裁剪
      res(newAudioBuffer);
    });
  }
  
  // 开始播放音频
  const handleClickPlay = () => {
    const clipBuffer = await cilpAudio(startTime, endTime);
    // 建立音频链接
    const audioSourceNode = audioCtx.createBufferSource();
    audioSourceNode.buffer = clipBuffer;
    audioSourceNode.connect(audioCtx.destination);
    audioSourceNode.start(0);  
  };
  
  return (
    <div>
      <Button type="primary" onClick={handleClickPlay}>
        播放
      </Button>
    </div>
  );
}
问题一:剪切耗时问题

音频剪辑由于涉及到文件编辑,所以其耗时问题可能会尤为我们所关注,在上面代码中,我们看到异步动作一共有三个,可能会比较耗时:

  • 请求音频资源并转换成 arrayBuffer
  • 将 arrayBuffer 转换成 audioBuffer
  • 剪切音频

我以一个 31分45秒的音频为例,从中截取其中 25分10秒 至 25分20秒,看一下这三步各自花的时长并在控制台打印一下:

从打印结果可以看出,前两步的动作都是非常耗时的,而第三步剪切音频的动作耗时很少,这个等待时间用户完全可以接受。(剪切音频的时长会随着要剪切的音频段时长的增加而增加,但不受时间段具体开始的位置影响,剪切出一个 100秒的也大概只需要 40ms 左右,这个时长也完全可以接受,而且实际一句字幕的时长通常在 10s 以内)

好在前两步耗时动作是一次性的,我们只需要在初始化时进行这两个动作,不必每次剪切音频都执行。初始化时等待过长用户可能难以接受,所以我们可以采取兜底策略,将方案三和方案四结合:前两步动作初始化完成前,用户的播放采用方案三;剪切完成后,播放采用方案四。

问题二:音频播放进度的显示

本方案中,我们完成采用了 Web Audio API 来进行音频的控制,不借助 <audio> ,所以没有 UI 展示播放进度。对于不同情形,我们可以有不同的解决方案:

  • 音频进度的 UI 是自己展示的:比较遗憾的是,Web Audio API 中没有提供当前音频播放时间的 api,AudioContext 中有一个 currentTime 属性,它返回的是从创建了音频上下文到现在的时间。我们可以通过它做一些处理,来获取当前的音频播放时间:

    ini 复制代码
      let start = 0;
      // 开始播放时,将 audioCtx.currentTime 赋值给 current
      audioSourceNode.start(0);
      start = audioCtx.currentTime;
    
      // 要获取当前播放时间是,可以通过 audioCtx.currentTime - start 获取
      const currentTime = audioCtx.currentTime - start;
  • 对于使用原生 <audio> 控制的,我们可以开始播放时,播放的声音采用本方案中的 Web Audio API 播放,同时静音播放 <audio> 以展示播放进度,Web Audio API 播放结束时停止 <audio> 的播放:
ini 复制代码
// 播放声音
audioSourceNode.start(0);
// 将 audio 标签的开始播放时间同步至 startTime
audioRef.current.currentTime = startTime;
// 静音播放 audio 展示进度
audioRef.current.volume = 0;
audioRef.current.play();

audioSourceNode.onended = () => {
  audioRef.current.pause();
};

服务端音频剪切

服务端初始方案

一开始的字幕数据中,每一条字幕都有对应的音视频,当然这不是为了实现精准播放,而是为了 C 端的部分场景例如一条字幕会有对应练习题,练习题要展示该小视频片段的封面图,后来移除了每条字幕的音视频片段,改为对应切图。有以下的几个可能会有的疑问解答:

  • 为什么不能将对应的音视频应用在精准播放:字幕调整时间轴时,需要即时播放声音,从上传数据到剪切完成拿到新的音视频片段耗时太久,体验太差。
  • 为什么后来移除了每条字幕的音视频片段,改为对应切图:字幕的保存一开始是每次编辑字幕后进行全量的保存,服务端无法识别有哪些字幕是修改过了的,所以都是全量去切音视频,耗时太久
  • 后来将修改字幕改成单句提交,可以应用音视频剪辑吗?:一条字幕的时间校准往往要修改多次时间轴,修改动作是实时保存的,意味着会进行很多次剪辑,性能损耗同样较高

设想优化方案

该优化方案应该应用在视频云那一层,而不是服务端,因为视频云可以直接拿到音频源文件,但我们想要访问源文件对应的片段时,只需要在源文件地址后拼接上起止时间,视频云剪切对应的片段并返回即可。下面我们 nodejs 模拟视频云服务来实现该方案。

比较遗憾的是,node 端没有 Web Audio API,我们使用 ffmepg 来进行音视频的剪切(我对其没有深入研究,可以自己了解一下)。实现一个音频剪切方法 cutAudio

javascript 复制代码
const ffmpeg = require('fluent-ffmpeg');
const pathToFfmpeg = require('ffmpeg-static');
const ffprobe = require('ffprobe-static');

const cutAudio = async (sourcePath, outputPath, startTime, endTime) => {
  await new Promise((resolve, reject) => {
    ffmpeg(sourcePath)
      .setFfmpegPath(pathToFfmpeg)
      .setFfprobePath(ffprobe.path)
      .output(outputPath)
      .setStartTime(startTime)
      .setDuration(endTime - startTime)
      .withAudioCodec('copy')
      .on('end', function (err) {
        if (!err) {
          console.log('conversion Done');
          resolve(1);
        }
      })
      .on('error', function (err) {
        console.log('error: ', err);
        reject(err);
      })
      .run();
  });
};
module.exports.cutAudio = cutAudio;

用户访问对应文件拼接了对应的起止时间后,先对音频进行剪切,再返回对应的音频片段:

ini 复制代码
const http = require('http');
const fs = require('fs');
const path = require('path');
const { cutAudio } = require('./utils');

// 获取源文件 arrayBuffer
const mp3Path = path.resolve(__dirname, './audio.mp3');

const server = http.createServer(async (req, res) => {
  const url = req.url;
  console.log(req.query, req.params);
  if (url.startsWith('/audio?')) {
    const audioSize = url.replace('/audio?', '');
    const [start, end] = audioSize.split('-');
    const sourcePath = mp3Path;
    const targetPath = path.resolve(
      __dirname,
      `./audio_${start.replaceAll(/./g, '-')}_${end.replaceAll(/./g, '-')}.mp3`,
    );
    await cutAudio(sourcePath, targetPath, start, end);
    fs.createReadStream(targetPath).pipe(res);
  }
});

server.listen(4567, () => {
  console.log('srever is running on 4567...');
});

这样做有几个好处:

  1. 服务端不每次调整字幕时就剪辑音频,当 C 端用到对应的音频片段再去剪辑就可
  1. 如果切片段及网络请求速度够快,可以通过服务端,实现音频精准播放

总结

本文总结了 Web Audio API 以及音频剪切等几种实现精准控制音频播放的方案,同时讲述了一些前端二进制、音频以及 node 服务端的知识,大家以后如果遇到类似的业务场景时,希望对大家能够有所帮助。

相关推荐
Sam90296 分钟前
【Webpack--013】SourceMap源码映射设置
前端·webpack·node.js
Python私教1 小时前
Go语言现代web开发15 Mutex 互斥锁
开发语言·前端·golang
A阳俊yi1 小时前
Vue(13)——router-link
前端·javascript·vue.js
小明说Java1 小时前
Vue3祖孙组件通信探秘:运用provide与inject实现跨层级数据传递
前端
好看资源平台1 小时前
前端框架对比与选择:如何在现代Web开发中做出最佳决策
前端·前端框架
4triumph1 小时前
Vue.js教程笔记
前端·vue.js
程序员大金1 小时前
基于SSM+Vue+MySQL的酒店管理系统
前端·vue.js·后端·mysql·spring·tomcat·mybatis
清灵xmf1 小时前
提前解锁 Vue 3.5 的新特性
前端·javascript·vue.js·vue3.5
Jiaberrr2 小时前
教你如何在微信小程序中轻松实现人脸识别功能
javascript·微信小程序·小程序·人脸识别·百度ai
白云~️2 小时前
监听html元素是否被删除,删除之后重新生成被删除的元素
前端·javascript·html