Files
CamTalk/backend/internal/ai/tts/mimo_test.go
cfy666 da5c727d8c fix: 修复语音与文字不同步的问题,改为句子级流式播放
之前前端 TTSPlayer 攒齐所有音频片段后才播放,导致文字全部显示后才开始语音。
改为后端每句 TTS 发送 is_last: true,前端收到每句即加入播放队列,第一句到达即开始播放。

- 后端 Chunk 结构体新增 Final 字段,区分句子结束和整轮结束
- 前端 TTSPlayer 重写为队列式播放,onended 回调自动衔接下一句
- 同步更新接口文档和测试用例
2026-06-14 13:54:49 +08:00

415 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package tts
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"go.uber.org/zap"
)
// mockMiMoTTSServer 创建模拟 MiMo TTS API 的 HTTP 服务器。
func mockMiMoTTSServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
t.Helper()
return httptest.NewServer(handler)
}
// buildMiMoTTSResponse 构造 MiMo TTS 非流式响应 JSON。
func buildMiMoTTSResponse(audioData string) []byte {
resp := mimoTTSResponse{
Choices: []struct {
Message struct {
Audio struct {
Data string `json:"data"`
} `json:"audio"`
} `json:"message"`
}{
{
Message: struct {
Audio struct {
Data string `json:"data"`
} `json:"audio"`
}{
Audio: struct {
Data string `json:"data"`
}{Data: audioData},
},
},
},
}
data, _ := json.Marshal(resp)
return data
}
func TestMiMoService_SynthesizeStream_Success(t *testing.T) {
var callCount int32
srv := mockMiMoTTSServer(t, func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&callCount, 1)
if r.Method != http.MethodPost {
t.Errorf("method = %s, want POST", r.Method)
}
if !strings.Contains(r.URL.Path, "/chat/completions") {
t.Errorf("path = %s, should contain /chat/completions", r.URL.Path)
}
// 验证 api-key 认证头
apiKey := r.Header.Get("api-key")
if apiKey != "test-key" {
t.Errorf("api-key = %q, want %q", apiKey, "test-key")
}
// 验证请求体
body, _ := io.ReadAll(r.Body)
var req mimoTTSRequest
if err := json.Unmarshal(body, &req); err != nil {
t.Errorf("unmarshal request: %v", err)
}
if req.Model != "mimo-v2.5-tts" {
t.Errorf("model = %q, want %q", req.Model, "mimo-v2.5-tts")
}
if len(req.Messages) != 1 || req.Messages[0].Role != "assistant" {
t.Errorf("expected 1 assistant message, got %d messages", len(req.Messages))
}
if req.Audio.Voice != "冰糖" {
t.Errorf("voice = %q, want %q", req.Audio.Voice, "冰糖")
}
if req.Audio.Format != "mp3" {
t.Errorf("format = %q, want %q", req.Audio.Format, "mp3")
}
// 返回假音频数据base64 编码)
audioB64 := base64.StdEncoding.EncodeToString([]byte("fake-mp3-data"))
w.Header().Set("Content-Type", "application/json")
w.Write(buildMiMoTTSResponse(audioB64))
})
defer srv.Close()
svc := NewMiMoService("test-key", "mimo-v2.5-tts", "冰糖", srv.URL, 5, 30, zap.NewNop().Sugar())
textStream := sendSentences("你好", "世界", "")
ch, err := svc.SynthesizeStream(context.Background(), textStream, Options{
Voice: "冰糖", OutputFmt: "mp3", SampleRate: 24000,
})
if err != nil {
t.Fatalf("SynthesizeStream() error: %v", err)
}
var chunks []Chunk
for c := range ch {
chunks = append(chunks, c)
}
// 应该有 3 个音频 chunk + 1 个 Final 标记
if len(chunks) != 4 {
t.Fatalf("got %d chunks, want 4", len(chunks))
}
// 验证前 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 true (sentence end)", i)
}
if chunks[i].Final {
t.Errorf("chunk[%d].Final should be false", i)
}
}
// 验证最后一个是 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")
}
// 验证调用了 3 次 API3 个句子)
if atomic.LoadInt32(&callCount) != 3 {
t.Errorf("API called %d times, want 3", callCount)
}
}
func TestMiMoService_SynthesizeStream_APIError(t *testing.T) {
srv := mockMiMoTTSServer(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(w, "internal error")
})
defer srv.Close()
svc := NewMiMoService("test-key", "mimo-v2.5-tts", "冰糖", srv.URL, 5, 30, zap.NewNop().Sugar())
textStream := sendSentences("你好")
ch, err := svc.SynthesizeStream(context.Background(), textStream, Options{})
if err != nil {
t.Fatalf("SynthesizeStream() error: %v", err)
}
// 应该只有一个 Final chunk音频被跳过
var chunks []Chunk
for c := range ch {
chunks = append(chunks, c)
}
if len(chunks) != 1 {
t.Fatalf("got %d chunks, want 1 (Final only)", len(chunks))
}
if !chunks[0].Final {
t.Error("chunk should be Final")
}
}
func TestMiMoService_SynthesizeStream_Timeout(t *testing.T) {
srv := mockMiMoTTSServer(t, func(w http.ResponseWriter, r *http.Request) {
time.Sleep(3 * time.Second)
audioB64 := base64.StdEncoding.EncodeToString([]byte("late-mp3"))
w.Header().Set("Content-Type", "application/json")
w.Write(buildMiMoTTSResponse(audioB64))
})
defer srv.Close()
// 1 秒超时
svc := NewMiMoService("test-key", "mimo-v2.5-tts", "冰糖", srv.URL, 1, 30, zap.NewNop().Sugar())
textStream := sendSentences("很长的句子")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ch, err := svc.SynthesizeStream(ctx, textStream, Options{})
if err != nil {
t.Fatalf("SynthesizeStream() error: %v", err)
}
var chunks []Chunk
for c := range ch {
chunks = append(chunks, c)
}
// 超时后音频被跳过,只有 Final
if len(chunks) != 1 {
t.Fatalf("got %d chunks, want 1", len(chunks))
}
if !chunks[0].Final {
t.Error("chunk should be Final")
}
}
func TestMiMoService_SynthesizeStream_EmptyText(t *testing.T) {
var callCount int32
srv := mockMiMoTTSServer(t, func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&callCount, 1)
audioB64 := base64.StdEncoding.EncodeToString([]byte("mp3"))
w.Header().Set("Content-Type", "application/json")
w.Write(buildMiMoTTSResponse(audioB64))
})
defer srv.Close()
svc := NewMiMoService("test-key", "mimo-v2.5-tts", "冰糖", srv.URL, 5, 30, zap.NewNop().Sugar())
// 空句子应该被跳过
textStream := sendSentences("", "你好", "")
ch, err := svc.SynthesizeStream(context.Background(), textStream, Options{})
if err != nil {
t.Fatalf("SynthesizeStream() error: %v", err)
}
var chunks []Chunk
for c := range ch {
chunks = append(chunks, c)
}
// 只有 "你好" 应该被合成
if atomic.LoadInt32(&callCount) != 1 {
t.Errorf("API called %d times, want 1", callCount)
}
// 1 个音频IsLast: true+ 1 个 Final
if len(chunks) != 2 {
t.Fatalf("got %d chunks, want 2", len(chunks))
}
}
func TestMiMoService_SynthesizeStream_ContextCancelled(t *testing.T) {
srv := mockMiMoTTSServer(t, func(w http.ResponseWriter, r *http.Request) {
audioB64 := base64.StdEncoding.EncodeToString([]byte("mp3"))
w.Header().Set("Content-Type", "application/json")
w.Write(buildMiMoTTSResponse(audioB64))
})
defer srv.Close()
svc := NewMiMoService("test-key", "mimo-v2.5-tts", "冰糖", srv.URL, 5, 30, zap.NewNop().Sugar())
textStream := make(chan string, 3)
textStream <- "第一句"
textStream <- "第二句"
textStream <- "第三句"
close(textStream)
ctx, cancel := context.WithCancel(context.Background())
// 立即取消
cancel()
ch, err := svc.SynthesizeStream(ctx, textStream, Options{})
if err != nil {
t.Fatalf("SynthesizeStream() error: %v", err)
}
// 消费 channel应该很快结束
var count int
for range ch {
count++
}
// 可能收到 0 个或 1 个 chunk取决于时序
t.Logf("received %d chunks after context cancel", count)
}
func TestMiMoService_SynthesizeStream_PartialFailure(t *testing.T) {
var callCount int32
srv := mockMiMoTTSServer(t, func(w http.ResponseWriter, r *http.Request) {
n := atomic.AddInt32(&callCount, 1)
if n == 2 {
// 第二个句子失败
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(w, "error")
return
}
audioB64 := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("mp3-%d", n)))
w.Header().Set("Content-Type", "application/json")
w.Write(buildMiMoTTSResponse(audioB64))
})
defer srv.Close()
svc := NewMiMoService("test-key", "mimo-v2.5-tts", "冰糖", srv.URL, 5, 30, zap.NewNop().Sugar())
textStream := sendSentences("第一句", "第二句", "第三句")
ch, err := svc.SynthesizeStream(context.Background(), textStream, Options{})
if err != nil {
t.Fatalf("SynthesizeStream() error: %v", err)
}
var chunks []Chunk
for c := range ch {
chunks = append(chunks, c)
}
// 2 个成功音频IsLast: true+ 1 个 Final第二句被跳过
if len(chunks) != 3 {
t.Fatalf("got %d chunks, want 3", len(chunks))
}
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")
}
}
func TestMiMoService_SynthesizeStream_CustomVoice(t *testing.T) {
srv := mockMiMoTTSServer(t, func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var req mimoTTSRequest
if err := json.Unmarshal(body, &req); err != nil {
t.Errorf("unmarshal request: %v", err)
}
if req.Audio.Voice != "茉莉" {
t.Errorf("voice = %q, want %q", req.Audio.Voice, "茉莉")
}
audioB64 := base64.StdEncoding.EncodeToString([]byte("mp3"))
w.Header().Set("Content-Type", "application/json")
w.Write(buildMiMoTTSResponse(audioB64))
})
defer srv.Close()
svc := NewMiMoService("test-key", "mimo-v2.5-tts", "冰糖", srv.URL, 5, 30, zap.NewNop().Sugar())
textStream := sendSentences("你好")
ch, err := svc.SynthesizeStream(context.Background(), textStream, Options{Voice: "茉莉"})
if err != nil {
t.Fatalf("SynthesizeStream() error: %v", err)
}
for range ch {
}
}
func TestMiMoService_SynthesizeStream_DefaultVoice(t *testing.T) {
srv := mockMiMoTTSServer(t, func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var req mimoTTSRequest
if err := json.Unmarshal(body, &req); err != nil {
t.Errorf("unmarshal request: %v", err)
}
// 未指定 voice 时应使用默认 "冰糖"
if req.Audio.Voice != "冰糖" {
t.Errorf("voice = %q, want %q (default)", req.Audio.Voice, "冰糖")
}
audioB64 := base64.StdEncoding.EncodeToString([]byte("mp3"))
w.Header().Set("Content-Type", "application/json")
w.Write(buildMiMoTTSResponse(audioB64))
})
defer srv.Close()
// 不指定 voice
svc := NewMiMoService("test-key", "mimo-v2.5-tts", "冰糖", srv.URL, 5, 30, zap.NewNop().Sugar())
textStream := sendSentences("你好")
ch, err := svc.SynthesizeStream(context.Background(), textStream, Options{})
if err != nil {
t.Fatalf("SynthesizeStream() error: %v", err)
}
for range ch {
}
}
func TestMiMoService_SynthesizeStream_EmptyAudioData(t *testing.T) {
srv := mockMiMoTTSServer(t, func(w http.ResponseWriter, r *http.Request) {
// 返回空音频数据
w.Header().Set("Content-Type", "application/json")
w.Write(buildMiMoTTSResponse(""))
})
defer srv.Close()
svc := NewMiMoService("test-key", "mimo-v2.5-tts", "冰糖", srv.URL, 5, 30, zap.NewNop().Sugar())
textStream := sendSentences("你好")
ch, err := svc.SynthesizeStream(context.Background(), textStream, Options{})
if err != nil {
t.Fatalf("SynthesizeStream() error: %v", err)
}
var chunks []Chunk
for c := range ch {
chunks = append(chunks, c)
}
// 空音频数据导致错误,句子被跳过,只有 Final
if len(chunks) != 1 {
t.Fatalf("got %d chunks, want 1", len(chunks))
}
if !chunks[0].Final {
t.Error("chunk should be Final")
}
}