fix: 修复语音与文字不同步的问题,改为句子级流式播放

之前前端 TTSPlayer 攒齐所有音频片段后才播放,导致文字全部显示后才开始语音。
改为后端每句 TTS 发送 is_last: true,前端收到每句即加入播放队列,第一句到达即开始播放。

- 后端 Chunk 结构体新增 Final 字段,区分句子结束和整轮结束
- 前端 TTSPlayer 重写为队列式播放,onended 回调自动衔接下一句
- 同步更新接口文档和测试用例
This commit is contained in:
2026-06-14 13:54:49 +08:00
parent 81c2b64e5f
commit da5c727d8c
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 { select {
case ch <- Chunk{Audio: audio, IsLast: false}: case ch <- Chunk{Audio: audio, IsLast: true, Final: false}:
case <-ctx.Done(): case <-ctx.Done():
return return
} }
} }
// textStream 关闭,发送 IsLast 标记 // textStream 关闭,发送 Final 标记
select { select {
case ch <- Chunk{Audio: nil, IsLast: true}: case ch <- Chunk{Audio: nil, IsLast: false, Final: true}:
case <-ctx.Done(): case <-ctx.Done():
} }
}() }()

View File

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

View File

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

View File

@@ -74,24 +74,27 @@ func TestOpenAIService_SynthesizeStream_Success(t *testing.T) {
chunks = append(chunks, c) chunks = append(chunks, c)
} }
// 应该有 3 个音频 chunk + 1 个 IsLast 标记 // 应该有 3 个音频 chunk + 1 个 Final 标记
if len(chunks) != 4 { if len(chunks) != 4 {
t.Fatalf("got %d chunks, want 4", len(chunks)) t.Fatalf("got %d chunks, want 4", len(chunks))
} }
// 验证前 3 个有音频数据 // 验证前 3 个有音频数据IsLast 为 true每句结束
for i := 0; i < 3; i++ { for i := 0; i < 3; i++ {
if string(chunks[i].Audio) != "fake-mp3-data" { if string(chunks[i].Audio) != "fake-mp3-data" {
t.Errorf("chunk[%d].Audio = %q, want %q", i, 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 { if !chunks[i].IsLast {
t.Errorf("chunk[%d].IsLast should be false", i) 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 // 验证最后一个是 Final整轮结束
if !chunks[3].IsLast { if !chunks[3].Final {
t.Error("last chunk should be IsLast") t.Error("last chunk should be Final")
} }
if chunks[3].Audio != nil { if chunks[3].Audio != nil {
t.Error("last chunk Audio should be 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) t.Fatalf("SynthesizeStream() error: %v", err)
} }
// 应该只有一个 IsLast chunk音频被跳过 // 应该只有一个 Final chunk音频被跳过
var chunks []Chunk var chunks []Chunk
for c := range ch { for c := range ch {
chunks = append(chunks, c) chunks = append(chunks, c)
} }
if len(chunks) != 1 { 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 { if !chunks[0].Final {
t.Error("chunk should be IsLast") t.Error("chunk should be Final")
} }
} }
@@ -159,12 +162,12 @@ func TestOpenAIService_SynthesizeStream_Timeout(t *testing.T) {
chunks = append(chunks, c) chunks = append(chunks, c)
} }
// 超时后音频被跳过,只有 IsLast // 超时后音频被跳过,只有 Final
if len(chunks) != 1 { if len(chunks) != 1 {
t.Fatalf("got %d chunks, want 1", len(chunks)) t.Fatalf("got %d chunks, want 1", len(chunks))
} }
if !chunks[0].IsLast { if !chunks[0].Final {
t.Error("chunk should be IsLast") 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) t.Errorf("API called %d times, want 1", callCount)
} }
// 1 个音频 + 1 个 IsLast // 1 个音频IsLast: true+ 1 个 Final
if len(chunks) != 2 { if len(chunks) != 2 {
t.Fatalf("got %d chunks, want 2", len(chunks)) t.Fatalf("got %d chunks, want 2", len(chunks))
} }
@@ -266,12 +269,18 @@ func TestOpenAIService_SynthesizeStream_PartialFailure(t *testing.T) {
chunks = append(chunks, c) chunks = append(chunks, c)
} }
// 2 个成功音频 + 1 个 IsLast(第二句被跳过) // 2 个成功音频IsLast: true+ 1 个 Final(第二句被跳过)
if len(chunks) != 3 { if len(chunks) != 3 {
t.Fatalf("got %d chunks, want 3", len(chunks)) t.Fatalf("got %d chunks, want 3", len(chunks))
} }
if !chunks[len(chunks)-1].IsLast { if !chunks[0].IsLast {
t.Error("last chunk should be 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 一个音频片段。 // Chunk 一个音频片段。
type Chunk struct { type Chunk struct {
Audio []byte // MP3 音频数据(未 Base64 编码) 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"` RequestID string `json:"request_id"`
Audio string `json:"audio"` // base64 Audio string `json:"audio"` // base64
MimeType string `json:"mime_type"` // "audio/mp3" 或 "audio/pcm" 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 消息。 // WsError 服务端 error 消息。

View File

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

View File

@@ -147,10 +147,16 @@ interface TTSAudioMessage {
request_id: string; request_id: string;
audio: string; // Base64 编码的音频片段 audio: string; // Base64 编码的音频片段
mime_type: string; // "audio/mp3" 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` 回调中自动衔接。 1. **排队播放**:收到 `is_last: true` 时,将该句子的音频片段拼接为 Blob URL 并加入播放队列。第一到达即开始播放,后续句子`onended` 回调中自动衔接。
2. **错误容错**:单个片段播放失败时跳过,继续播放队列中下一个,不中断整个回复。 2. **错误容错**:单个句子播放失败时跳过,继续播放队列中下一个,不中断整个回复。
3. **打断清理**:收到 `interrupt` 消息或用户触发打断时,清空播放队列并释放所有 Blob URL。 3. **打断清理**:收到 `interrupt` 消息或用户触发打断时,清空播放队列并释放所有 Blob URL。
4. **类型锁定**`mime_type` 字段固定为 `"audio/mp3"`,前端解码时直接使用,无需运行时判断。 4. **类型锁定**`mime_type` 字段固定为 `"audio/mp3"`,前端解码时直接使用,无需运行时判断。
```typescript ```typescript
// 前端播放器伪代码 // 前端播放器伪代码
class AudioPlayer { class AudioPlayer {
private queue: string[] = []; // Blob URL 队列 private sentenceChunks: string[] = []; // 当前句子的音频片段缓冲
private queue: string[] = []; // 已就绪的句子 Blob URL 队列
enqueue(base64: string) { enqueue(base64: string, isLast: boolean) {
const url = decodeBase64Audio(base64, "audio/mp3"); this.sentenceChunks.push(base64);
if (isLast) {
// 当前句子音频完整,拼接并加入播放队列
const url = decodeBase64Audio(this.sentenceChunks.join(""), "audio/mp3");
this.sentenceChunks = [];
this.queue.push(url); this.queue.push(url);
if (this.queue.length === 1) this.playNext(); // 第一到了就开始播 if (this.queue.length === 1) this.playNext(); // 第一到了就开始播
}
} }
private playNext() { private playNext() {
if (this.queue.length === 0) return; if (this.queue.length === 0) return;
const audio = new Audio(this.queue[0]); const audio = new Audio(this.queue.shift()!);
audio.onended = () => { URL.revokeObjectURL(this.queue.shift()!); this.playNext(); }; audio.onended = () => { URL.revokeObjectURL(audio.src); this.playNext(); };
audio.onerror = () => { URL.revokeObjectURL(this.queue.shift()!); this.playNext(); }; audio.onerror = () => { URL.revokeObjectURL(audio.src); this.playNext(); };
audio.play(); 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} ------| |<-- llm_done {full_text} ------|
| | | |
|<-- tts_audio {audio} ---------| (TTS 音频流) |<-- 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} ------| |<-- llm_done {full_text} ------|
| | | |
|<-- tts_audio {audio} ---------| (TTS 音频流) |<-- 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": case "tts_audio":
getTTSPlayer().enqueue(msg.audio, msg.mime_type, msg.is_last); getTTSPlayer().enqueue(msg.audio, msg.mime_type, msg.is_last);
if (!msg.is_last) {
setIsAudioPlaying(true); setIsAudioPlaying(true);
}
break; break;
case "error": case "error":

View File

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