Files
CamTalk/backend/internal/ai/tts/mimo_test.go

406 lines
11 KiB
Go
Raw 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 个 IsLast 标记
if len(chunks) != 4 {
t.Fatalf("got %d chunks, want 4", len(chunks))
}
// 验证前 3 个有音频数据
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)
}
}
// 验证最后一个是 IsLast
if !chunks[3].IsLast {
t.Error("last chunk should be IsLast")
}
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)
}
// 应该只有一个 IsLast 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))
}
if !chunks[0].IsLast {
t.Error("chunk should be IsLast")
}
}
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)
}
// 超时后音频被跳过,只有 IsLast
if len(chunks) != 1 {
t.Fatalf("got %d chunks, want 1", len(chunks))
}
if !chunks[0].IsLast {
t.Error("chunk should be IsLast")
}
}
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 个音频 + 1 个 IsLast
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 个成功音频 + 1 个 IsLast第二句被跳过
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")
}
}
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)
}
// 空音频数据导致错误,句子被跳过,只有 IsLast
if len(chunks) != 1 {
t.Fatalf("got %d chunks, want 1", len(chunks))
}
if !chunks[0].IsLast {
t.Error("chunk should be IsLast")
}
}