在做前端实时语音合成(TTS)时,很多人都会遇到同样的问题:
本文将带你走一遍真实的排坑过程,最终用一种优雅的方式在浏览器中实现 低延迟、不卡顿、无杂音 的流式 TTS 播放。
一开始我们采用最直观的方式:
decodeAudioData() 解码成 PCMAudioBufferSourceNode 播放听起来没什么问题,但结果是:
这是绝大多数开发者第一次尝试流式 TTS 时会踩的坑。
后来我们换成浏览器原生支持的 MediaSource Extensions (MSE) 技术:
MediaSource 作为音频流容器mediaSource.addSourceBuffer('audio/mpeg') 声明要接收 MP3 流ArrayBuffersourceBuffer.appendBuffer(buffer) 追加到播放流结果立刻变得丝滑:
✅ 接收即播,低延迟
✅ 无缝拼接,无杂音
✅ 不再卡顿,性能极佳
✅ 兼容所有现代浏览器(Chrome / Edge / Firefox / Safari)
下面是一份可直接使用的封装类,只需传入 Base64 MP3 数据块,即可实现流式播放:
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140 |
/**
* StreamingTTSPlayer.ts
*
* 一个用于播放「流式 Base64 MP3」音频的播放器。
* 使用 MediaSource + SourceBuffer 实现边接收边播放,不卡顿无杂音。
*/export
interface
StreamingTTSPlayerOptions {
/** 用于监听播放器状态(ready、error 等)的回调 */
onEvent?: (
event
:
string
, data?: any) =>
void
;}export
class
StreamingTTSPlayer {
private
audio: ifun.psnsd.com 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 ysu.psnsd.com = 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 uaxlfe.psnsd.com =
'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(rank.psnsd.com 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"
&& cbwvih.psnsd.com
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[i] = binary.charCodeAt(i);
}
return
buffer.buffer;
}
/** 触发事件回调 */
private
emit(
event
:
string
, data?: any) {
this
.onEvent?.(
event
, data);
}} |
|
1
2
3
4
5
6
7 |
const
player =
new
StreamingTTSPlayer();// 每收到一块 TTS 音频数据就塞进去ws.=> {
const
data = JSON.parse(e.data);
if
(data.audio) player.receiveBase64(data.audio);}; |
来自 “ ITPUB博客 ” ,链接:https://blog.itpub.net/70048949/viewspace-3109390/ ,如需转载,请注明出处,否则将追究法律责任。