diff --git a/backend/internal/ai/tts/mimo.go b/backend/internal/ai/tts/mimo.go index 5e80cbb..717b9f7 100644 --- a/backend/internal/ai/tts/mimo.go +++ b/backend/internal/ai/tts/mimo.go @@ -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(): } }() diff --git a/backend/internal/ai/tts/mimo_test.go b/backend/internal/ai/tts/mimo_test.go index 0d6b1de..35d2641 100644 --- a/backend/internal/ai/tts/mimo_test.go +++ b/backend/internal/ai/tts/mimo_test.go @@ -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") } } diff --git a/backend/internal/ai/tts/openai.go b/backend/internal/ai/tts/openai.go index 8741ce6..ba7f788 100644 --- a/backend/internal/ai/tts/openai.go +++ b/backend/internal/ai/tts/openai.go @@ -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(): } }() diff --git a/backend/internal/ai/tts/openai_test.go b/backend/internal/ai/tts/openai_test.go index 7a0d7f5..2d7b8ad 100644 --- a/backend/internal/ai/tts/openai_test.go +++ b/backend/internal/ai/tts/openai_test.go @@ -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") } } diff --git a/backend/internal/ai/tts/tts.go b/backend/internal/ai/tts/tts.go index 9b08d0e..e478ff7 100644 --- a/backend/internal/ai/tts/tts.go +++ b/backend/internal/ai/tts/tts.go @@ -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) } diff --git a/backend/internal/models/models.go b/backend/internal/models/models.go index 49347ea..b07924c 100644 --- a/backend/internal/models/models.go +++ b/backend/internal/models/models.go @@ -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 消息。 diff --git a/backend/internal/orchestrator/pipeline.go b/backend/internal/orchestrator/pipeline.go index cb103b8..914b70e 100644 --- a/backend/internal/orchestrator/pipeline.go +++ b/backend/internal/orchestrator/pipeline.go @@ -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) } diff --git a/docs/03-接口文档.md b/docs/03-接口文档.md index 220524f..f26a79a 100644 --- a/docs/03-接口文档.md +++ b/docs/03-接口文档.md @@ -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 全部结束) ``` --- diff --git a/frontend/src/hooks/useVisionSession.ts b/frontend/src/hooks/useVisionSession.ts index b3e278c..0c09935 100644 --- a/frontend/src/hooks/useVisionSession.ts +++ b/frontend/src/hooks/useVisionSession.ts @@ -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); - } + setIsAudioPlaying(true); break; case "error": diff --git a/frontend/src/lib/ttsPlayer.ts b/frontend/src/lib/ttsPlayer.ts index 3228b12..ca1aac7 100644 --- a/frontend/src/lib/ttsPlayer.ts +++ b/frontend/src/lib/ttsPlayer.ts @@ -1,18 +1,21 @@ // ============================================================ // TTS Player — 语音播放器 -// 职责:收集后端流式 tts_audio 片段,拼接后播放 -// 格式:MVP 仅支持 audio/mp3,pcm 为 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); + } } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index fe21e36..935fd22 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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 {