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

@@ -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")
}
}