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

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

310 lines
8.1 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"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"go.uber.org/zap"
)
// mockTTSServer 创建模拟 OpenAI TTS API 的 HTTP 服务器。
func mockTTSServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
t.Helper()
return httptest.NewServer(handler)
}
// sendSentences 向 channel 发送句子并关闭。
func sendSentences(sentences ...string) <-chan string {
ch := make(chan string, len(sentences))
for _, s := range sentences {
ch <- s
}
close(ch)
return ch
}
func TestOpenAIService_SynthesizeStream_Success(t *testing.T) {
var callCount int32
srv := mockTTSServer(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, "/audio/speech") {
t.Errorf("path = %s, should contain /audio/speech", r.URL.Path)
}
auth := r.Header.Get("Authorization")
if auth != "Bearer test-key" {
t.Errorf("Authorization = %q, want %q", auth, "Bearer test-key")
}
// 验证请求体
body, _ := io.ReadAll(r.Body)
if !strings.Contains(string(body), "tts-1") {
t.Errorf("request body should contain model tts-1")
}
// 返回假 MP3 数据
w.Header().Set("Content-Type", "audio/mpeg")
fmt.Fprintf(w, "fake-mp3-data")
})
defer srv.Close()
svc := NewOpenAIService("test-key", "tts-1", "alloy", srv.URL, 1.0, 5, 30, zap.NewNop().Sugar())
textStream := sendSentences("你好", "世界", "")
ch, err := svc.SynthesizeStream(context.Background(), textStream, Options{
Voice: "alloy", Speed: 1.0, 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 TestOpenAIService_SynthesizeStream_APIError(t *testing.T) {
srv := mockTTSServer(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(w, "internal error")
})
defer srv.Close()
svc := NewOpenAIService("test-key", "tts-1", "alloy", srv.URL, 1.0, 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 TestOpenAIService_SynthesizeStream_Timeout(t *testing.T) {
srv := mockTTSServer(t, func(w http.ResponseWriter, r *http.Request) {
time.Sleep(3 * time.Second)
w.Header().Set("Content-Type", "audio/mpeg")
fmt.Fprintf(w, "late-mp3")
})
defer srv.Close()
// 1 秒超时
svc := NewOpenAIService("test-key", "tts-1", "alloy", srv.URL, 1.0, 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 TestOpenAIService_SynthesizeStream_EmptyText(t *testing.T) {
var callCount int32
srv := mockTTSServer(t, func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&callCount, 1)
w.Header().Set("Content-Type", "audio/mpeg")
fmt.Fprintf(w, "mp3")
})
defer srv.Close()
svc := NewOpenAIService("test-key", "tts-1", "alloy", srv.URL, 1.0, 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 TestOpenAIService_SynthesizeStream_ContextCancelled(t *testing.T) {
srv := mockTTSServer(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "audio/mpeg")
fmt.Fprintf(w, "mp3")
})
defer srv.Close()
svc := NewOpenAIService("test-key", "tts-1", "alloy", srv.URL, 1.0, 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 TestOpenAIService_SynthesizeStream_PartialFailure(t *testing.T) {
var callCount int32
srv := mockTTSServer(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
}
w.Header().Set("Content-Type", "audio/mpeg")
fmt.Fprintf(w, "mp3-%d", n)
})
defer srv.Close()
svc := NewOpenAIService("test-key", "tts-1", "alloy", srv.URL, 1.0, 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 TestOpenAIService_SynthesizeStream_CustomVoice(t *testing.T) {
srv := mockTTSServer(t, func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
if !strings.Contains(string(body), "nova") {
t.Errorf("request body should contain voice 'nova', got: %s", string(body))
}
w.Header().Set("Content-Type", "audio/mpeg")
fmt.Fprintf(w, "mp3")
})
defer srv.Close()
svc := NewOpenAIService("test-key", "tts-1", "alloy", srv.URL, 1.0, 5, 30, zap.NewNop().Sugar())
textStream := sendSentences("你好")
ch, err := svc.SynthesizeStream(context.Background(), textStream, Options{Voice: "nova"})
if err != nil {
t.Fatalf("SynthesizeStream() error: %v", err)
}
for range ch {
}
}