博客 / 詳情

返回

沒座!五分鐘帶你學會Vue中的音頻字幕同步播放功能(以VVT格式為例講解)

他們朝我扔泥巴,我拿泥巴種荷花;他們朝我扔巴巴,我用巴巴敲代碼,哦哦哦哦哦...

需求描述

  • 有一個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來講解

首先來一份字幕文件

字幕文件如何獲取

  • 這裏筆者推薦一些在線網站
  • 可以直接把純人聲音頻或者視頻轉出一個字幕文件
  • 比如這個熊貓字幕:在線字幕自動生成工具\_字幕製作\_語音轉字幕-熊貓字幕 (pdsub.com)
另外,可能部分道友會遇到想要把文本轉語音的同時,再生成對應的字幕,後續筆者也會出一篇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

user avatar landyliu 頭像 ch5nftr 頭像 zhuoooo 頭像
3 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.