他们朝我扔泥巴,我拿泥巴种荷花;他们朝我扔巴巴,我用巴巴敲代码,哦哦哦哦哦...

需求描述

  • 有一个MP3音频文件,在播放的时候,需要展示对应的字幕给到用户
  • 即为需要做到视频和音频同步的效果
  • 如下效果图
  • 演示地址:http://ashuai.work:8890/19

字幕文件的种类

常见的字幕文件,有三种

1. SRT格式(SubRip Subtitle)

最常见的字幕格式,包含了字幕文本、显示时间(开始和结束时间),文件结构简单、易于创建,如下简单示例:

1
00:00:01,000 --> 00:00:04,000
你好,这个世界

2
00:00:05,000 --> 00:00:08,000
这个世界,你好

2. VVT格式(WebVTT,Web Video Text Tracks)

HTML网页专属,前端最常用,支持HTML5视频元素。与SRT类似,但具有更多的功能,如HTML标签、文本样式和位置。如下简单示例:

WEBVTT



00:00:00.100 --> 00:00:02.175

不必说碧绿的菜畦,



00:00:02.125 --> 00:00:03.850

光滑的石井栏,

3. ASS格式(Advanced SubStation Alpha)

比较复杂的字幕文件,支持更多的样式和特效,如字体、颜色、位置等,常用于高质量的视频或动画字幕。用的少,如下示例:

[Script Info]
Title: Example Subtitle
Original Script: John Doe
ScriptType: v4.00+

[Events]
Dialogue: 0,0:00:01.00,0:00:05.00,Default,,0,0,0,,Hello, how are you?

就前端而言,VVT用的最多,因此本篇文章,我们以VVT来讲解

首先来一份字幕文件

字幕文件如何获取

另外,可能部分道友会遇到想要把文本转语音的同时,再生成对应的字幕,后续笔者也会出一篇TTS文章,敬请期待...

示例VVT字幕

WEBVTT


00:00:00.100 --> 00:00:02.175

不必说碧绿的菜畦,



00:00:02.125 --> 00:00:03.850

光滑的石井栏,



00:00:03.850 --> 00:00:05.713

高大的皂荚树,



00:00:05.713 --> 00:00:07.287

紫红的桑葚;



00:00:07.287 --> 00:00:10.350

也不必说鸣蝉在树叶里长吟,



00:00:10.350 --> 00:00:13.062

肥胖的黄蜂伏在菜花上,



00:00:13.062 --> 00:00:18.488

轻捷的叫天子(云雀)忽然从草间直窜向云霄里去了。



00:00:18.488 --> 00:00:21.000

单是周围的短短的泥墙根一带,



00:00:21.000 --> 00:00:22.738

就有无限趣味。



00:00:22.738 --> 00:00:24.438

油蛉在这里低唱,



00:00:24.438 --> 00:00:26.613

蟋蟀们在这里弹琴。



00:00:26.613 --> 00:00:28.337

翻开断砖来,



00:00:28.337 --> 00:00:30.113

有时会遇见蜈蚣;



00:00:30.113 --> 00:00:31.488

还有斑蝥,



00:00:31.488 --> 00:00:33.950

倘若用手指按住它的脊梁,



00:00:33.950 --> 00:00:35.625

便会“啪”的一声,



00:00:35.625 --> 00:00:38.175

从后窍喷出一阵烟雾。

一、audio标签形式之读取并加工展示字幕

1. 读取字幕

  • 这里把字幕文件,放在public文件夹下
  • 再使用fetch去得到对应字幕文件内容
onMounted(() => {
  getVvtData();
});

const getVvtData = async () => {
  // 获取当前字幕文件的路径
  const vvtUrl = new URL("/subtitles/1.vvt", import.meta.url).href;
  // 使用fetch请求,此路径下的字幕文件
  const response = await fetch(vvtUrl);
  // 状态判断
  if (!response.ok) throw new Error("网络错误或文件不存在");
  // 拿到字幕数据转成的文本
  const vvtData = await response.text();
  // 使用正则将字幕文件加工成JSON格式
  subtitles.value = parseVvtData(vvtData);
};
字幕文本不能直接使用,所以我们需要将其转成对象形式,才方便使用

2. 解析并加工成对象形式

// 解析字幕文件并将其转换为 JSON
const parseVvtData = (data) => {
  const subtitlePattern = /(\d{2}:\d{2}:\d{2}\.\d{3}) --> (\d{2}:\d{2}:\d{2}\.\d{3})\s*([\s\S]+?)(?=\n\d{2}:\d{2}:\d{2}\.\d{3}|$)/g;
  let matches;
  const parsedSubtitles = [];
  while ((matches = subtitlePattern.exec(data)) !== null) {
    const start = convertTimeToSeconds(matches[1]);
    const end = convertTimeToSeconds(matches[2]);
    const text = matches[3].trim();
    parsedSubtitles.push({
      start,
      end,
      text,
    });
  }
  return parsedSubtitles;
};

// 将字幕时间从字符串"00:00:05.000" 格式转换为秒数数字
const convertTimeToSeconds = (timeStr) => {
  const [hours, minutes, seconds] = timeStr.split(":");
  const [sec, ms] = seconds.split(".");
  return (
    parseInt(hours) * 3600 + parseInt(minutes) * 60 + parseInt(sec) + parseInt(ms) / 1000
  );
};

加工完毕以后,能得到这样的字幕数组对象,如下:

  • 即为,数组中每一项,都是一条字幕对象
  • 字幕对象记录了字幕开始时间,字幕结束时间,以及在开始结束时间之间,需要呈现的字幕文字
  • 这样的话,我们就可以在对应时间节点,展示对应字幕即可

3. 音频播放的时候,根据时间,找到对应的字幕展示即可

当音频播放的时候,audio标签,自带的timeupdate事件,可以拿到当前播放的时间是什么时间节点

<audio ref="myAudioRef" @timeupdate="timeupdate" controls :src="mp3"></audio>
// 展示字幕的div
<div v-if="currentSubtitle">{{ currentSubtitle.text }}</div>

const timeupdate = (e) => {
  // 当前音频播放的时间
  currentTime.value = e.target.currentTime;
  updateSubtitle(currentTime.value);
};

// 根据当前时间戳更新显示的字幕
const updateSubtitle = (curTime) => {
  // 根据播放的时间,找到当前播放的是哪一项
  const subtitle = subtitles.value.find(
    // 当前时间,大于字幕开始,小于字幕结束
    (sub) => curTime >= sub.start && curTime <= sub.end
  );
  // 找到对应字幕项
  currentSubtitle.value = subtitle || null;
};

4. 完整代码(单行字幕播放)

至于多行字幕,就是循环不断往后拼接即可,这里不赘述

<template>
  <div class="boxA">
    <h3>音频播放字幕同步出现——只显示单条</h3>
    <audio ref="myAudioRef" @timeupdate="timeupdate" controls :src="mp3"></audio>
    <div v-if="currentSubtitle">{{ currentSubtitle.text }}</div>
  </div>
</template>

<script setup>
import { ref, onMounted } from "vue";
import mp3 from "./1.mp3";

const myAudioRef = ref();
const currentTime = ref();
const subtitles = ref(); // 所有字幕数据
const currentSubtitle = ref(); // 当前显示的字幕项

onMounted(() => {
  getVvtData();
});

const getVvtData = async () => {
  // 获取当前字幕文件的路径
  const vvtUrl = new URL("/subtitles/1.vvt", import.meta.url).href;
  // 使用fetch请求,此路径下的字幕文件
  const response = await fetch(vvtUrl);
  // 状态判断
  if (!response.ok) throw new Error("网络错误或文件不存在");
  // 拿到字幕数据转成的文本
  const vvtData = await response.text();
  // 使用正则将字幕文件加工成JSON格式
  subtitles.value = parseVvtData(vvtData);
};

// 解析字幕文件并将其转换为 JSON
const parseVvtData = (data) => {
  const subtitlePattern = /(\d{2}:\d{2}:\d{2}\.\d{3}) --> (\d{2}:\d{2}:\d{2}\.\d{3})\s*([\s\S]+?)(?=\n\d{2}:\d{2}:\d{2}\.\d{3}|$)/g;
  let matches;
  const parsedSubtitles = [];
  while ((matches = subtitlePattern.exec(data)) !== null) {
    const start = convertTimeToSeconds(matches[1]);
    const end = convertTimeToSeconds(matches[2]);
    const text = matches[3].trim();
    parsedSubtitles.push({
      start,
      end,
      text,
    });
  }
  return parsedSubtitles;
};

// 将字幕时间从字符串"00:00:05.000" 格式转换为秒数数字
const convertTimeToSeconds = (timeStr) => {
  const [hours, minutes, seconds] = timeStr.split(":");
  const [sec, ms] = seconds.split(".");
  return (
    parseInt(hours) * 3600 + parseInt(minutes) * 60 + parseInt(sec) + parseInt(ms) / 1000
  );
};

const timeupdate = (e) => {
  currentTime.value = e.target.currentTime;
  updateSubtitle(currentTime.value);
};

// 根据当前时间戳更新显示的字幕
const updateSubtitle = (curTime) => {
  // 根据播放的时间,找到当前播放的是哪一项
  const subtitle = subtitles.value.find(
    // 当前时间,大于字幕开始,小于字幕结束
    (sub) => curTime >= sub.start && curTime <= sub.end
  );
  currentSubtitle.value = subtitle || null;
};
</script>

<style lang="less" scoped>
.boxA {
  height: 160px;
}
</style>
某些情况下,我们不能使用audio标签来播放音频,这个时候,就需要使用另外一种方式:window.AudioContext 去实例化一个音频播放器,q去对应播放音频,如下

二、AudioContext之读取并加工展示字幕

1. 读取字幕并加工字幕

  • 原理很简单,和上述的读取字幕一样,这里不赘述
  • 也是把public文件夹中字幕文件读取并解析
  • 最后得到字幕数组对象
  • 在AudioContext播放音频的时候,使用一个定时器,或者requestAnimationFrame之类的
  • 不断查找当前时间对应的字幕数据,直接展示到页面上

2. 当点击按钮时,播放音频且用定时器,查找字幕数组中的对应文件

如下html结构

<template>
  <div class="boxA">
    <button @click="play">播放音频</button>
    <!-- 循环出字幕内容 -->
    <div v-if="displayedSubtitles.length">
      <p v-for="(subtitle, index) in displayedSubtitles" :key="index">{{ subtitle }}</p>
    </div>
  </div>
</template>

注意,play方法的音频和字幕文件的处理使用

const subtitles = ref(); // 所有字幕数据
const displayedSubtitles = ref([]); // 当前显示的所有字幕项

// 创建 AudioContext 实例
const audioContext = new (window.AudioContext || window.webkitAudioContext)();

// 用于播放音频
const currentTime = ref(0); // 当前播放时间

const play = async () => {
  try {
    // 获取音频文件并转换为 ArrayBuffer
    const response = await fetch(mp3);
    const arrayBuffer = await response.arrayBuffer();

    // 解码音频数据
    const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);

    // 创建音频源
    const audioSource = audioContext.createBufferSource();
    audioSource.buffer = audioBuffer;

    // 连接音频源到输出(扬声器)
    audioSource.connect(audioContext.destination);

    // 播放音频
    audioSource.start();

    // 设置一个定时器,模拟 timeupdate 事件
    const intervalId = setInterval(() => {
      if (audioContext.state === "running") {
        currentTime.value = audioContext.currentTime;
        console.log("currentTime.value", currentTime.value.toFixed(3));
        updateSubtitle(currentTime.value);
      }

      // 停止定时器,当音频播放结束时
      if (audioContext.currentTime >= audioBuffer.duration) {
        clearInterval(intervalId);
      }
    }, 100); // 每100ms更新一次
  } catch (error) {
    console.error("音频播放失败:", error);
  }
};

// 根据当前时间戳更新显示的字幕
const updateSubtitle = (curTime) => {
  // 找到当前时间点应该显示的字幕
  const newSubtitles = subtitles.value.filter(
    (sub) => curTime >= sub.start && curTime <= sub.end
  );
  // 找到了,就将其添加到displayedSubtitles数组中
  if (newSubtitles.length > 0) {
    // 但是因为timeupdate触发频繁,所以追加前,要看看这条字幕是否存在过
    newSubtitles.forEach((subtitle) => {
      // 不存在,才去往里面追加
      if (!displayedSubtitles.value.includes(subtitle.text)) {
        displayedSubtitles.value.push(subtitle.text);
      }
    });
  }
};

3. 完整代码

在笔者的github上:https://github.com/shuirongshuifu/vue3-echarts5-example


水冗水孚
1.1k 声望595 粉丝

每一个不曾起舞的日子,都是对生命的辜负