Merge pull request 'fix: 修复语音与文字不同步的问题,改为句子级流式播放' (#67) from frontend-12 into develop
Some checks failed
Backend CI / ci (pull_request) Failing after 2m14s
Deploy / verify (pull_request) Failing after 1s
Deploy / deploy (pull_request) Has been skipped
Frontend CI / ci (pull_request) Successful in 56s

Reviewed-on: http://8.161.227.145:3000/XEngineers/CamTalk/pulls/67
This commit was merged in pull request #67.
This commit is contained in:
2026-06-14 13:55:47 +08:00
11 changed files with 149 additions and 113 deletions

View File

@@ -114,15 +114,15 @@ func (m *MiMoService) SynthesizeStream(ctx context.Context, textStream <-chan st
}
select {
case ch <- Chunk{Audio: audio, IsLast: false}:
case ch <- Chunk{Audio: audio, IsLast: true, Final: false}:
case <-ctx.Done():
return
}
}
// textStream 关闭,发送 IsLast 标记
// textStream 关闭,发送 Final 标记
select {
case ch <- Chunk{Audio: nil, IsLast: true}:
case ch <- Chunk{Audio: nil, IsLast: false, Final: true}:
case <-ctx.Done():
}
}()

View File

@@ -109,24 +109,27 @@ func TestMiMoService_SynthesizeStream_Success(t *testing.T) {
chunks = append(chunks, c)
}
// 应该有 3 个音频 chunk + 1 个 IsLast 标记
// 应该有 3 个音频 chunk + 1 个 Final 标记
if len(chunks) != 4 {
t.Fatalf("got %d chunks, want 4", len(chunks))
}
// 验证前 3 个有音频数据
// 验证前 3 个有音频数据IsLast 为 true每句结束
for i := 0; i < 3; i++ {
if string(chunks[i].Audio) != "fake-mp3-data" {
t.Errorf("chunk[%d].Audio = %q, want %q", i, string(chunks[i].Audio), "fake-mp3-data")
}
if chunks[i].IsLast {
t.Errorf("chunk[%d].IsLast should be false", i)
if !chunks[i].IsLast {
t.Errorf("chunk[%d].IsLast should be true (sentence end)", i)
}
if chunks[i].Final {
t.Errorf("chunk[%d].Final should be false", i)
}
}
// 验证最后一个是 IsLast
if !chunks[3].IsLast {
t.Error("last chunk should be IsLast")
// 验证最后一个是 Final整轮结束
if !chunks[3].Final {
t.Error("last chunk should be Final")
}
if chunks[3].Audio != nil {
t.Error("last chunk Audio should be nil")
@@ -154,17 +157,17 @@ func TestMiMoService_SynthesizeStream_APIError(t *testing.T) {
t.Fatalf("SynthesizeStream() error: %v", err)
}
// 应该只有一个 IsLast chunk音频被跳过
// 应该只有一个 Final chunk音频被跳过
var chunks []Chunk
for c := range ch {
chunks = append(chunks, c)
}
if len(chunks) != 1 {
t.Fatalf("got %d chunks, want 1 (IsLast only)", len(chunks))
t.Fatalf("got %d chunks, want 1 (Final only)", len(chunks))
}
if !chunks[0].IsLast {
t.Error("chunk should be IsLast")
if !chunks[0].Final {
t.Error("chunk should be Final")
}
}
@@ -195,12 +198,12 @@ func TestMiMoService_SynthesizeStream_Timeout(t *testing.T) {
chunks = append(chunks, c)
}
// 超时后音频被跳过,只有 IsLast
// 超时后音频被跳过,只有 Final
if len(chunks) != 1 {
t.Fatalf("got %d chunks, want 1", len(chunks))
}
if !chunks[0].IsLast {
t.Error("chunk should be IsLast")
if !chunks[0].Final {
t.Error("chunk should be Final")
}
}
@@ -234,7 +237,7 @@ func TestMiMoService_SynthesizeStream_EmptyText(t *testing.T) {
t.Errorf("API called %d times, want 1", callCount)
}
// 1 个音频 + 1 个 IsLast
// 1 个音频IsLast: true+ 1 个 Final
if len(chunks) != 2 {
t.Fatalf("got %d chunks, want 2", len(chunks))
}
@@ -304,12 +307,18 @@ func TestMiMoService_SynthesizeStream_PartialFailure(t *testing.T) {
chunks = append(chunks, c)
}
// 2 个成功音频 + 1 个 IsLast(第二句被跳过)
// 2 个成功音频IsLast: true+ 1 个 Final(第二句被跳过)
if len(chunks) != 3 {
t.Fatalf("got %d chunks, want 3", len(chunks))
}
if !chunks[len(chunks)-1].IsLast {
t.Error("last chunk should be IsLast")
if !chunks[0].IsLast {
t.Error("first audio chunk should be IsLast")
}
if !chunks[1].IsLast {
t.Error("second audio chunk should be IsLast")
}
if !chunks[len(chunks)-1].Final {
t.Error("last chunk should be Final")
}
}
@@ -395,11 +404,11 @@ func TestMiMoService_SynthesizeStream_EmptyAudioData(t *testing.T) {
chunks = append(chunks, c)
}
// 空音频数据导致错误,句子被跳过,只有 IsLast
// 空音频数据导致错误,句子被跳过,只有 Final
if len(chunks) != 1 {
t.Fatalf("got %d chunks, want 1", len(chunks))
}
if !chunks[0].IsLast {
t.Error("chunk should be IsLast")
if !chunks[0].Final {
t.Error("chunk should be Final")
}
}

View File

@@ -87,15 +87,15 @@ func (o *OpenAIService) SynthesizeStream(ctx context.Context, textStream <-chan
}
select {
case ch <- Chunk{Audio: audio, IsLast: false}:
case ch <- Chunk{Audio: audio, IsLast: true, Final: false}:
case <-ctx.Done():
return
}
}
// textStream 关闭,发送 IsLast 标记
// textStream 关闭,发送 Final 标记
select {
case ch <- Chunk{Audio: nil, IsLast: true}:
case ch <- Chunk{Audio: nil, IsLast: false, Final: true}:
case <-ctx.Done():
}
}()

View File

@@ -74,24 +74,27 @@ func TestOpenAIService_SynthesizeStream_Success(t *testing.T) {
chunks = append(chunks, c)
}
// 应该有 3 个音频 chunk + 1 个 IsLast 标记
// 应该有 3 个音频 chunk + 1 个 Final 标记
if len(chunks) != 4 {
t.Fatalf("got %d chunks, want 4", len(chunks))
}
// 验证前 3 个有音频数据
// 验证前 3 个有音频数据IsLast 为 true每句结束
for i := 0; i < 3; i++ {
if string(chunks[i].Audio) != "fake-mp3-data" {
t.Errorf("chunk[%d].Audio = %q, want %q", i, string(chunks[i].Audio), "fake-mp3-data")
}
if chunks[i].IsLast {
t.Errorf("chunk[%d].IsLast should be false", i)
if !chunks[i].IsLast {
t.Errorf("chunk[%d].IsLast should be true (sentence end)", i)
}
if chunks[i].Final {
t.Errorf("chunk[%d].Final should be false", i)
}
}
// 验证最后一个是 IsLast
if !chunks[3].IsLast {
t.Error("last chunk should be IsLast")
// 验证最后一个是 Final整轮结束
if !chunks[3].Final {
t.Error("last chunk should be Final")
}
if chunks[3].Audio != nil {
t.Error("last chunk Audio should be nil")
@@ -119,17 +122,17 @@ func TestOpenAIService_SynthesizeStream_APIError(t *testing.T) {
t.Fatalf("SynthesizeStream() error: %v", err)
}
// 应该只有一个 IsLast chunk音频被跳过
// 应该只有一个 Final chunk音频被跳过
var chunks []Chunk
for c := range ch {
chunks = append(chunks, c)
}
if len(chunks) != 1 {
t.Fatalf("got %d chunks, want 1 (IsLast only)", len(chunks))
t.Fatalf("got %d chunks, want 1 (Final only)", len(chunks))
}
if !chunks[0].IsLast {
t.Error("chunk should be IsLast")
if !chunks[0].Final {
t.Error("chunk should be Final")
}
}
@@ -159,12 +162,12 @@ func TestOpenAIService_SynthesizeStream_Timeout(t *testing.T) {
chunks = append(chunks, c)
}
// 超时后音频被跳过,只有 IsLast
// 超时后音频被跳过,只有 Final
if len(chunks) != 1 {
t.Fatalf("got %d chunks, want 1", len(chunks))
}
if !chunks[0].IsLast {
t.Error("chunk should be IsLast")
if !chunks[0].Final {
t.Error("chunk should be Final")
}
}
@@ -197,7 +200,7 @@ func TestOpenAIService_SynthesizeStream_EmptyText(t *testing.T) {
t.Errorf("API called %d times, want 1", callCount)
}
// 1 个音频 + 1 个 IsLast
// 1 个音频IsLast: true+ 1 个 Final
if len(chunks) != 2 {
t.Fatalf("got %d chunks, want 2", len(chunks))
}
@@ -266,12 +269,18 @@ func TestOpenAIService_SynthesizeStream_PartialFailure(t *testing.T) {
chunks = append(chunks, c)
}
// 2 个成功音频 + 1 个 IsLast(第二句被跳过)
// 2 个成功音频IsLast: true+ 1 个 Final(第二句被跳过)
if len(chunks) != 3 {
t.Fatalf("got %d chunks, want 3", len(chunks))
}
if !chunks[len(chunks)-1].IsLast {
t.Error("last chunk should be IsLast")
if !chunks[0].IsLast {
t.Error("first audio chunk should be IsLast")
}
if !chunks[1].IsLast {
t.Error("second audio chunk should be IsLast")
}
if !chunks[len(chunks)-1].Final {
t.Error("last chunk should be Final")
}
}

View File

@@ -21,5 +21,6 @@ type Options struct {
// Chunk 一个音频片段。
type Chunk struct {
Audio []byte // MP3 音频数据(未 Base64 编码)
IsLast bool // 是否为最后一片
IsLast bool // 当前句子是否为最后一片(每句结束时为 true
Final bool // 整轮 TTS 是否结束(所有句子合成完毕后为 true此时 Audio 为 nil
}

View File

@@ -112,7 +112,8 @@ type WsTTSAudio struct {
RequestID string `json:"request_id"`
Audio string `json:"audio"` // base64
MimeType string `json:"mime_type"` // "audio/mp3" 或 "audio/pcm"
IsLast bool `json:"is_last"`
IsLast bool `json:"is_last"` // 当前句子的音频是否完整(每句结束时为 true
Final bool `json:"final"` // 整轮 TTS 是否结束(所有句子合成完毕后为 true
}
// WsError 服务端 error 消息。

View File

@@ -368,6 +368,7 @@ func (p *Pipeline) synthesizeTTS(
Audio: audioBase64,
MimeType: "audio/mp3",
IsLast: chunk.IsLast,
Final: chunk.Final,
}); err != nil {
log.Errorw("发送 tts_audio 失败", "error", err)
}

View File

@@ -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");
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(); // 第一到了就开始播
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 全部结束)
```
---

View File

@@ -266,9 +266,7 @@ export function useVisionSession() {
case "tts_audio":
getTTSPlayer().enqueue(msg.audio, msg.mime_type, msg.is_last);
if (!msg.is_last) {
setIsAudioPlaying(true);
}
break;
case "error":

View File

@@ -1,18 +1,21 @@
// ============================================================
// TTS Player — 语音播放器
// 职责:收后端流式 tts_audio 片段,拼接后播放
// 格式MVP 仅支持 audio/mp3pcm 为 TODO
// 职责:收后端流式 tts_audio 片段,按句子排队播放
// 设计:第一句到达即开始播放,后续句子在 onended 回调中自动衔接
// ============================================================
type OnEndCallback = () => void;
export class TTSPlayer {
private chunks: string[] = [];
/** 当前句子的音频片段缓冲(每句可能含多个 chunk */
private sentenceChunks: string[] = [];
/** 已就绪的句子 Blob URL 播放队列 */
private queue: string[] = [];
private audio: HTMLAudioElement | null = null;
private _isPlaying = false;
private onEndCallback: OnEndCallback | null = null;
/** 注册播放完成回调 */
/** 注册播放完成回调(所有句子播完后触发) */
onEnd(cb: OnEndCallback): void {
this.onEndCallback = cb;
}
@@ -25,38 +28,40 @@ export class TTSPlayer {
/**
* 入队一个 TTS 音频片段
* @param base64 Base64 编码的音频数据
* @param mimeType 音频格式("audio/mp3" 或 "audio/pcm"
* @param isLast 是否为最后一个片段
* @param mimeType 音频格式("audio/mp3"
* @param isLast 当前句子是否合成完毕(每句结束时为 true
*/
enqueue(base64: string, mimeType: string, isLast: boolean): void {
this.chunks.push(base64);
this.sentenceChunks.push(base64);
if (isLast) {
this.play(mimeType);
// 当前句子的音频已完整,拼接并加入播放队列
const combined = this.sentenceChunks.join("");
this.sentenceChunks = [];
const url = this.base64ToBlobUrl(combined, mimeType);
this.queue.push(url);
// 如果当前没有在播,立即开始播放
if (!this._isPlaying) {
this.playNext();
}
}
}
/** 停止播放并清空缓冲区 */
/** 停止播放并清空所有队列 */
stop(): void {
if (this.audio) {
this.audio.pause();
this.audio.removeAttribute("src");
this.audio = null;
}
this.chunks = [];
this._isPlaying = false;
}
/**
* 停止当前正在播放的音频(不清空 chunks
* 用于 play() 开始前,防止新旧音频重叠
*/
private stopCurrentAudio(): void {
if (this.audio) {
this.audio.pause();
this.audio.removeAttribute("src");
this.audio = null;
// 释放队列中的 Blob URL
for (const url of this.queue) {
URL.revokeObjectURL(url);
}
this.queue = [];
this.sentenceChunks = [];
this._isPlaying = false;
}
@@ -70,51 +75,48 @@ export class TTSPlayer {
this.audio?.play();
}
/** 拼接所有片段并播放 */
private play(mimeType: string): void {
if (this.chunks.length === 0) return;
// 停止当前正在播放的音频,防止重叠
this.stopCurrentAudio();
// 拼接所有 Base64 片段
const combined = this.chunks.join("");
this.chunks = [];
// Base64 → Uint8Array → Blob
const binary = atob(combined);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
/** 播放队列中的下一个句子 */
private playNext(): void {
if (this.queue.length === 0) {
this._isPlaying = false;
this.onEndCallback?.();
return;
}
const blob = new Blob([bytes], { type: mimeType });
const url = URL.createObjectURL(blob);
// 播放
const url = this.queue.shift()!;
const audio = new Audio(url);
this.audio = audio;
this._isPlaying = true;
audio.onended = () => {
URL.revokeObjectURL(url);
this._isPlaying = false;
this.audio = null;
this.onEndCallback?.();
this.playNext();
};
audio.onerror = () => {
console.error("[TTS] 播放失败");
console.error("[TTS] 播放失败,跳过");
URL.revokeObjectURL(url);
this._isPlaying = false;
this.audio = null;
this.onEndCallback?.();
this.playNext();
};
audio.play().catch((err) => {
console.error("[TTS] play() 被拒绝:", err);
this._isPlaying = false;
URL.revokeObjectURL(url);
this.audio = null;
this.onEndCallback?.();
this.playNext();
});
}
/** Base64 字符串转 Blob URL */
private base64ToBlobUrl(base64: string, mimeType: string): string {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
const blob = new Blob([bytes], { type: mimeType });
return URL.createObjectURL(blob);
}
}

View File

@@ -115,7 +115,8 @@ export interface TTSAudioMessage {
request_id: string;
audio: string; // Base64 音频片段
mime_type: string; // "audio/mp3" 或 "audio/pcm"
is_last: boolean;
is_last: boolean; // 当前句子的音频是否完整(每句结束时为 true
final: boolean; // 整轮 TTS 是否结束
}
export interface ErrorMessage {