在Web前端實現(xiàn)流式TTS播放的排坑過程
前言
在做前端實時語音合成(TTS)時,很多人都會遇到同樣的問題:
- 播放出來的語音一頓一頓的,很卡頓
- 聲音中夾雜“咔嗒”聲、雜音、斷裂
- 明明音頻格式是 MP3,也無法做到“接收到就播放”
本文將帶你走一遍真實的排坑過程,最終用一種優(yōu)雅的方式在瀏覽器中實現(xiàn) 低延遲、不卡頓、無雜音 的流式 TTS 播放。
問題的起點:AudioBufferSourceNode 方案
一開始我們采用最直觀的方式:
- 后端流式返回 Base64 MP3 塊
- 前端每收到一塊:
- Base64 → ArrayBuffer
- 用
decodeAudioData()解碼成 PCM - 用
AudioBufferSourceNode播放
聽起來沒什么問題,但結(jié)果是:
- 頻繁卡頓:每次解碼都要等主線程空閑,播放中途就被打斷
- 雜音爆音:每塊是獨立的 AudioNode,時間軸無法無縫拼接
- 延遲明顯:必須解碼完成才能播,沒法“邊下邊播”
這是絕大多數(shù)開發(fā)者第一次嘗試流式 TTS 時會踩的坑。
真正流暢的做法:MediaSource + SourceBuffer
后來我們換成瀏覽器原生支持的 MediaSource Extensions (MSE) 技術:
- 創(chuàng)建
MediaSource作為音頻流容器 mediaSource.addSourceBuffer('audio/mpeg')聲明要接收 MP3 流- 每收到一塊 Base64 MP3:
- 轉(zhuǎn)為
ArrayBuffer sourceBuffer.appendBuffer(buffer)追加到播放流
- 轉(zhuǎn)為
- 瀏覽器底層會自動解碼 + 緩沖 + 拼接播放
結(jié)果立刻變得絲滑:
? 接收即播,低延遲
? 無縫拼接,無雜音
? 不再卡頓,性能極佳
? 兼容所有現(xiàn)代瀏覽器(Chrome / Edge / Firefox / Safari)
最終實現(xiàn):StreamingTTSPlayer
下面是一份可直接使用的封裝類,只需傳入 Base64 MP3 數(shù)據(jù)塊,即可實現(xiàn)流式播放:
/**
* StreamingTTSPlayer.ts
*
* 一個用于播放「流式 Base64 MP3」音頻的播放器。
* 使用 MediaSource + SourceBuffer 實現(xiàn)邊接收邊播放,不卡頓無雜音。
*/
export interface StreamingTTSPlayerOptions {
/** 用于監(jiān)聽播放器狀態(tài)(ready、error 等)的回調(diào) */
onEvent?: (event: string, data?: any) => void;
}
export class StreamingTTSPlayer {
private audio: HTMLAudioElement; // 播放用的 <audio> 元素
private mediaSource: MediaSource; // 媒體源(支持流式拼接)
private sourceBuffer: SourceBuffer | null = null; // 用于接收音頻塊的緩沖區(qū)
private queue: ArrayBuffer[] = []; // 等待寫入 SourceBuffer 的音頻塊隊列
private isBufferUpdating = false; // 是否正在寫入數(shù)據(jù)(避免并發(fā))
private onEvent?: (event: string, data?: any) => void; // 事件回調(diào)
constructor(options?: StreamingTTSPlayerOptions) {
this.onEvent = options?.onEvent;
// 1. 創(chuàng)建 HTMLAudioElement
this.audio = new Audio();
// 2. 創(chuàng)建 MediaSource 并掛載到 audio 元素
this.mediaSource = new MediaSource();
this.audio.src = URL.createObjectURL(this.mediaSource);
// 3. 等待 mediaSource 初始化完成
this.mediaSource.addEventListener("sourceopen", () => {
try {
// 4. 創(chuàng)建一個 MP3 類型的 SourceBuffer,用于接收音頻塊
this.sourceBuffer = this.mediaSource.addSourceBuffer('audio/mpeg');
// 5. 設置拼接模式為 sequence(自動按順序拼接)
this.sourceBuffer.mode = 'sequence';
// 6. 每次 appendBuffer 完成后觸發(fā) updateend,繼續(xù)處理隊列
this.sourceBuffer.addEventListener('updateend', () => this.feedQueue());
this.emit("ready");
} catch (err) {
console.error("Failed to add sourceBuffer:", err);
this.emit("error", err);
}
});
// 監(jiān)聽 audio 元素播放錯誤
this.audio.addEventListener("error", (e) => {
this.emit("error", e);
});
}
/**
* 接收一段 base64 MP3 數(shù)據(jù)塊并放入播放隊列
* @param base64 base64 編碼的 MP3 數(shù)據(jù)塊
* @param autoPlay 是否自動開始播放(默認 true)
*/
receiveBase64(base64: string, autoPlay = true) {
try {
const buffer = this.base64ToArrayBuffer(base64);
this.queue.push(buffer);
this.feedQueue(); // 立即嘗試送入 SourceBuffer
if (autoPlay) this.play();
} catch (err) {
console.error("TTS decode error:", err);
this.emit("error", err);
}
}
/** 播放(如果已暫停) */
play() {
if (this.audio.paused) {
this.audio.play().catch(() => {});
}
}
/** 暫停播放 */
pause() {
if (!this.audio.paused) {
this.audio.pause();
}
}
/**
* 停止播放并清空緩沖
* (會丟棄所有未播放的數(shù)據(jù))
*/
stop() {
this.pause();
this.queue = [];
if (this.mediaSource.readyState === "open" && this.sourceBuffer && !this.sourceBuffer.updating) {
try {
this.sourceBuffer.abort(); // 終止當前的緩沖區(qū)寫入
} catch {}
}
this.audio.currentTime = 0;
}
/**
* 內(nèi)部方法:嘗試把隊列中的數(shù)據(jù) append 到 SourceBuffer
*/
private feedQueue() {
// 沒有 SourceBuffer 或正在寫入時不處理
if (!this.sourceBuffer || this.isBufferUpdating) return;
if (this.queue.length === 0) return;
if (!this.sourceBuffer.updating) {
const chunk = this.queue.shift()!;
try {
this.isBufferUpdating = true;
this.sourceBuffer.appendBuffer(chunk); // 核心:追加 MP3 數(shù)據(jù)到播放流
this.isBufferUpdating = false;
} catch (err) {
console.error("Failed to append buffer:", err);
this.emit("error", err);
}
}
}
/**
* Base64 -> ArrayBuffer 轉(zhuǎn)換工具
*/
private base64ToArrayBuffer(base64: string): ArrayBuffer {
const binary = atob(base64.replace(/^data:audio\/\w+;base64,/, ""));
const len = binary.length;
const buffer = new Uint8Array(len);
for (let i = 0; i < len; i++) {
buffer[i] = binary.charCodeAt(i);
}
return buffer.buffer;
}
/** 觸發(fā)事件回調(diào) */
private emit(event: string, data?: any) {
this.onEvent?.(event, data);
}
}
使用
const player = new StreamingTTSPlayer();
// 每收到一塊 TTS 音頻數(shù)據(jù)就塞進去
ws.onmessage = (e) => {
const data = JSON.parse(e.data);
if (data.audio) player.receiveBase64(data.audio);
};總結(jié)
到此這篇關于在Web前端實現(xiàn)流式TTS播放的文章就介紹到這了,更多相關Web前端流式TTS播放內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關文章希望大家以后多多支持腳本之家!
相關文章
layui實現(xiàn)把數(shù)據(jù)表格時間戳轉(zhuǎn)換為時間格式的例子
今天小編就為大家分享一篇layui實現(xiàn)把數(shù)據(jù)表格時間戳轉(zhuǎn)換為時間格式的例子,具有很好的參考價值,希望對大家有所幫助。一起跟隨小編過來看看吧2019-09-09
Angular+Bootstrap+Spring Boot實現(xiàn)分頁功能實例代碼
這篇文章主要介紹了Angular+Bootstrap+Spring Boot實現(xiàn)分頁功能實例代碼,需要的朋友可以參考下2017-07-07
uniapp中解析markdown支持網(wǎng)頁和小程序?qū)崿F(xiàn)示例
這篇文章主要為大家介紹了uniapp中解析markdown支持網(wǎng)頁和小程序?qū)崿F(xiàn)示例,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2023-09-09
javascript showModalDialog 多層模態(tài)窗口實現(xiàn)頁面提交及刷新的代碼
javascript 多層模態(tài)窗口showModalDialog頁面提交及刷新2009-11-11
JS中new?Date().Format("yyyy-MM-dd")?報錯的解決
這篇文章主要介紹了JS中new?Date().Format("yyyy-MM-dd")?報錯的解決方案,具有很好的參考價值,希望對大家有所幫助。如有錯誤或未考慮完全的地方,望不吝賜教2023-01-01
Bootstrap的popover(彈出框)2秒后定時消失的實現(xiàn)代碼
Bootstrap Popover(彈出框)是使用定制的 Jquery 插件創(chuàng)建的。它可以用來顯示任何元素的一些信息。這篇文章主要介紹了Bootstrap的popover(彈出框)2秒后定時消失功能,需要的朋友參考下2017-02-02

