在 Web 前端实现流式 TTS 播放
<h1 data-id="heading-0">🧑💻 写在开头</h1><p>点赞 + 收藏 === 学会🤣🤣🤣</p>
<div>
<div>
<h2 data-id="heading-0">🧠 在 Web 前端实现流式 TTS 播放:从卡顿杂音到丝滑顺畅的演进之路</h2>
<p>在做前端实时语音合成(TTS)时,很多人都会遇到同样的问题:</p>
<ul>
<li>播放出来的语音一顿一顿的,很卡顿</li>
<li>声音中夹杂“咔嗒”声、杂音、断裂</li>
<li>明明音频格式是 MP3,也无法做到“接收到就播放”</li>
</ul>
<p>本文将带你走一遍真实的排坑过程,最终用一种优雅的方式在浏览器中实现 <strong>低延迟、不卡顿、无杂音</strong> 的流式 TTS 播放。</p>
<hr>
<h3 data-id="heading-1">💥 问题的起点:AudioBufferSourceNode 方案</h3>
<p>一开始我们采用最直观的方式:</p>
<ol>
<li>后端流式返回 Base64 MP3 块</li>
<li>前端每收到一块:
<ul>
<li>Base64 → ArrayBuffer</li>
<li>用 <code>decodeAudioData()</code> 解码成 PCM</li>
<li>用 <code>AudioBufferSourceNode</code> 播放</li>
</ul>
</li>
</ol>
<p>听起来没什么问题,但结果是:</p>
<ul>
<li><strong>频繁卡顿</strong>:每次解码都要等主线程空闲,播放中途就被打断</li>
<li><strong>杂音爆音</strong>:每块是独立的 AudioNode,时间轴无法无缝拼接</li>
<li><strong>延迟明显</strong>:必须解码完成才能播,没法“边下边播”</li>
</ul>
<p>这是绝大多数开发者第一次尝试流式 TTS 时会踩的坑。</p>
<hr>
<h3 data-id="heading-2">🚀 真正流畅的做法:MediaSource + SourceBuffer</h3>
<p>后来我们换成浏览器原生支持的 <strong>MediaSource Extensions (MSE)</strong> 技术:</p>
<ul>
<li>创建 <code>MediaSource</code> 作为音频流容器</li>
<li><code>mediaSource.addSourceBuffer('audio/mpeg')</code> 声明要接收 MP3 流</li>
<li>每收到一块 Base64 MP3:
<ul>
<li>转为 <code>ArrayBuffer</code></li>
<li><code>sourceBuffer.appendBuffer(buffer)</code> 追加到播放流</li>
</ul>
</li>
<li>浏览器底层会自动解码 + 缓冲 + 拼接播放</li>
</ul>
<p>结果立刻变得丝滑:</p>
<p>✅ 接收即播,低延迟<br>
✅ 无缝拼接,无杂音<br>
✅ 不再卡顿,性能极佳<br>
✅ 兼容所有现代浏览器(Chrome / Edge / Firefox / Safari)</p>
<hr>
<h3 data-id="heading-3">🧩 最终实现:StreamingTTSPlayer</h3>
<p>下面是一份可直接使用的封装类,只需传入 Base64 MP3 数据块,即可实现流式播放:</p>
</div>
<div class="cnblogs_Highlighter">
<pre class="brush:csharp;gutter:true;">/**
* StreamingTTSPlayer.ts
*
* 一个用于播放「流式 Base64 MP3」音频的播放器。
* 使用 MediaSource + SourceBuffer 实现边接收边播放,不卡顿无杂音。
*/
export interface StreamingTTSPlayerOptions {
/** 用于监听播放器状态(ready、error 等)的回调 */
onEvent?: (event: string, data?: any) => void;
}
export class StreamingTTSPlayer {
private audio: HTMLAudioElement; // 播放用的 <audio> 元素
private mediaSource: MediaSource; // 媒体源(支持流式拼接)
private sourceBuffer: SourceBuffer | null = null; // 用于接收音频块的缓冲区
private queue: ArrayBuffer[] = []; // 等待写入 SourceBuffer 的音频块队列
private isBufferUpdating = false; // 是否正在写入数据(避免并发)
private onEvent?: (event: string, data?: any) => void; // 事件回调
constructor(options?: StreamingTTSPlayerOptions) {
this.onEvent = options?.onEvent;
// 1. 创建 HTMLAudioElement
this.audio = new Audio();
// 2. 创建 MediaSource 并挂载到 audio 元素
this.mediaSource = new MediaSource();
this.audio.src = URL.createObjectURL(this.mediaSource);
// 3. 等待 mediaSource 初始化完成
this.mediaSource.addEventListener("sourceopen", () => {
try {
// 4. 创建一个 MP3 类型的 SourceBuffer,用于接收音频块
this.sourceBuffer = this.mediaSource.addSourceBuffer('audio/mpeg');
// 5. 设置拼接模式为 sequence(自动按顺序拼接)
this.sourceBuffer.mode = 'sequence';
// 6. 每次 appendBuffer 完成后触发 updateend,继续处理队列
this.sourceBuffer.addEventListener('updateend', () => this.feedQueue());
this.emit("ready");
} catch (err) {
console.error("Failed to add sourceBuffer:", err);
this.emit("error", err);
}
});
// 监听 audio 元素播放错误
this.audio.addEventListener("error", (e) => {
this.emit("error", e);
});
}
/**
* 接收一段 base64 MP3 数据块并放入播放队列
* @param base64 base64 编码的 MP3 数据块
* @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();
}
}
/**
* 停止播放并清空缓冲
* (会丢弃所有未播放的数据)
*/
stop() {
this.pause();
this.queue = [];
if (this.mediaSource.readyState === "open" && this.sourceBuffer && !this.sourceBuffer.updating) {
try {
this.sourceBuffer.abort(); // 终止当前的缓冲区写入
} catch {}
}
this.audio.currentTime = 0;
}
/**
* 内部方法:尝试把队列中的数据 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 数据到播放流
this.isBufferUpdating = false;
} catch (err) {
console.error("Failed to append buffer:", err);
this.emit("error", err);
}
}
}
/**
* Base64 -> ArrayBuffer 转换工具
*/
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 = binary.charCodeAt(i);
}
return buffer.buffer;
}
/** 触发事件回调 */
private emit(event: string, data?: any) {
this.onEvent?.(event, data);
}
}</pre>
</div>
</div>
<div>
<h3>使用</h3>
<div>
<div class="cnblogs_Highlighter">
<pre class="brush:csharp;gutter:true;">const player = new StreamingTTSPlayer();
// 每收到一块 TTS 音频数据就塞进去
ws.onmessage = (e) => {
const data = JSON.parse(e.data);
if (data.audio) player.receiveBase64(data.audio);
};</pre>
</div>
</div>
</div>
<div>
<h3 id="tid-D8HBxE">如果对您有所帮助,欢迎您点个关注,我会定时更新技术文档,大家一起讨论学习,一起进步。</h3>
</div>
<p><em><img src="https://img2024.cnblogs.com/blog/2149129/202501/2149129-20250122165814748-630765389.png" alt="" loading="lazy"></em></p><br><br>
来源:https://www.cnblogs.com/smileZAZ/p/19430536
頁:
[1]