fix: 修复语音与文字不同步的问题,改为句子级流式播放
之前前端 TTSPlayer 攒齐所有音频片段后才播放,导致文字全部显示后才开始语音。 改为后端每句 TTS 发送 is_last: true,前端收到每句即加入播放队列,第一句到达即开始播放。 - 后端 Chunk 结构体新增 Final 字段,区分句子结束和整轮结束 - 前端 TTSPlayer 重写为队列式播放,onended 回调自动衔接下一句 - 同步更新接口文档和测试用例
This commit is contained in:
@@ -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():
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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():
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 消息。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user