fix: 修复语音与文字不同步的问题,改为句子级流式播放
之前前端 TTSPlayer 攒齐所有音频片段后才播放,导致文字全部显示后才开始语音。 改为后端每句 TTS 发送 is_last: true,前端收到每句即加入播放队列,第一句到达即开始播放。 - 后端 Chunk 结构体新增 Final 字段,区分句子结束和整轮结束 - 前端 TTSPlayer 重写为队列式播放,onended 回调自动衔接下一句 - 同步更新接口文档和测试用例
This commit is contained in:
@@ -147,10 +147,16 @@ interface TTSAudioMessage {
|
||||
request_id: string;
|
||||
audio: string; // Base64 编码的音频片段
|
||||
mime_type: string; // "audio/mp3"
|
||||
is_last: boolean; // 是否为最后一片
|
||||
is_last: boolean; // 当前句子的音频是否完整(每句结束时为 true)
|
||||
final: boolean; // 整轮 TTS 是否结束(所有句子合成完毕后为 true)
|
||||
}
|
||||
```
|
||||
|
||||
**字段语义**:
|
||||
|
||||
- `is_last`: 每个句子合成完毕后为 `true`,前端收到此信号即可将该句子加入播放队列。每句 TTS 音频由一次独立的 API 调用生成,对应一个 `tts_audio` 消息。
|
||||
- `final`: 所有句子合成完毕后为 `true`(此时 `audio` 为空字符串),用于前端判断本轮 TTS 已全部到齐。
|
||||
|
||||
**音频格式规范**(前端播放依赖此约定):
|
||||
|
||||
| 属性 | 值 | 说明 |
|
||||
@@ -165,31 +171,37 @@ interface TTSAudioMessage {
|
||||
|
||||
**前端播放实现要点**:
|
||||
|
||||
1. **排队播放**:收到 `tts_audio` 时,将 Base64 解码为 Blob URL 并加入播放队列。第一片到达即开始播放,后续片段在 `onended` 回调中自动衔接。
|
||||
2. **错误容错**:单个片段播放失败时跳过,继续播放队列中下一个,不中断整个回复。
|
||||
1. **排队播放**:收到 `is_last: true` 时,将该句子的音频片段拼接为 Blob URL 并加入播放队列。第一句到达即开始播放,后续句子在 `onended` 回调中自动衔接。
|
||||
2. **错误容错**:单个句子播放失败时跳过,继续播放队列中下一个,不中断整个回复。
|
||||
3. **打断清理**:收到 `interrupt` 消息或用户触发打断时,清空播放队列并释放所有 Blob URL。
|
||||
4. **类型锁定**:`mime_type` 字段固定为 `"audio/mp3"`,前端解码时直接使用,无需运行时判断。
|
||||
|
||||
```typescript
|
||||
// 前端播放器伪代码
|
||||
class AudioPlayer {
|
||||
private queue: string[] = []; // Blob URL 队列
|
||||
private sentenceChunks: string[] = []; // 当前句子的音频片段缓冲
|
||||
private queue: string[] = []; // 已就绪的句子 Blob URL 队列
|
||||
|
||||
enqueue(base64: string) {
|
||||
const url = decodeBase64Audio(base64, "audio/mp3");
|
||||
this.queue.push(url);
|
||||
if (this.queue.length === 1) this.playNext(); // 第一片到了就开始播
|
||||
enqueue(base64: string, isLast: boolean) {
|
||||
this.sentenceChunks.push(base64);
|
||||
if (isLast) {
|
||||
// 当前句子音频完整,拼接并加入播放队列
|
||||
const url = decodeBase64Audio(this.sentenceChunks.join(""), "audio/mp3");
|
||||
this.sentenceChunks = [];
|
||||
this.queue.push(url);
|
||||
if (this.queue.length === 1) this.playNext(); // 第一句到了就开始播
|
||||
}
|
||||
}
|
||||
|
||||
private playNext() {
|
||||
if (this.queue.length === 0) return;
|
||||
const audio = new Audio(this.queue[0]);
|
||||
audio.onended = () => { URL.revokeObjectURL(this.queue.shift()!); this.playNext(); };
|
||||
audio.onerror = () => { URL.revokeObjectURL(this.queue.shift()!); this.playNext(); };
|
||||
const audio = new Audio(this.queue.shift()!);
|
||||
audio.onended = () => { URL.revokeObjectURL(audio.src); this.playNext(); };
|
||||
audio.onerror = () => { URL.revokeObjectURL(audio.src); this.playNext(); };
|
||||
audio.play();
|
||||
}
|
||||
|
||||
clear() { this.queue.forEach(url => URL.revokeObjectURL(url)); this.queue = []; }
|
||||
clear() { this.queue.forEach(url => URL.revokeObjectURL(url)); this.queue = []; this.sentenceChunks = []; }
|
||||
}
|
||||
```
|
||||
|
||||
@@ -228,7 +240,8 @@ Client Server
|
||||
|<-- llm_done {full_text} ------|
|
||||
| |
|
||||
|<-- tts_audio {audio} ---------| (TTS 音频流)
|
||||
|<-- tts_audio {is_last: true} -|
|
||||
|<-- tts_audio {is_last: true} -| (句子完成)
|
||||
|<-- tts_audio {final: true} ---| (TTS 全部结束)
|
||||
```
|
||||
|
||||
**文本输入模式**(麦克风关闭,手动输入文字):
|
||||
@@ -244,7 +257,8 @@ Client Server
|
||||
|<-- llm_done {full_text} ------|
|
||||
| |
|
||||
|<-- tts_audio {audio} ---------| (TTS 音频流)
|
||||
|<-- tts_audio {is_last: true} -|
|
||||
|<-- tts_audio {is_last: true} -| (句子完成)
|
||||
|<-- tts_audio {final: true} ---| (TTS 全部结束)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user