feat: 添加 MiMo TTS 语音合成服务
实现基于 Xiaomi MiMo TTS API 的语音合成 provider,使用冰糖音色。 - 新增 MiMoService 实现 tts.Service 接口 - 使用 chat/completions 格式,与 MiMo STT 保持一致 - 在 main.go 添加 TTS provider 切换逻辑(mimo/xiaomi) - 配置文件默认音色设为冰糖 - 包含完整测试覆盖(9 个测试用例)
This commit is contained in:
212
backend/internal/ai/tts/mimo.go
Normal file
212
backend/internal/ai/tts/mimo.go
Normal file
@@ -0,0 +1,212 @@
|
||||
package tts
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// MiMoService 基于 Xiaomi MiMo TTS API 的语音合成实现。
|
||||
// 接口兼容 OpenAI chat/completions 格式,通过 messages 传递待合成文本与风格指令。
|
||||
type MiMoService struct {
|
||||
apiKey string
|
||||
model string
|
||||
voice string
|
||||
endpoint string
|
||||
timeout time.Duration
|
||||
logger *zap.SugaredLogger
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewMiMoService 创建 MiMo TTS 服务。
|
||||
func NewMiMoService(apiKey, model, voice, endpoint string, timeoutSec int, logger *zap.SugaredLogger) *MiMoService {
|
||||
if model == "" {
|
||||
model = "mimo-v2.5-tts"
|
||||
}
|
||||
if voice == "" {
|
||||
voice = "冰糖"
|
||||
}
|
||||
if endpoint == "" {
|
||||
endpoint = "https://api.xiaomimimo.com/v1"
|
||||
}
|
||||
timeout := time.Duration(timeoutSec) * time.Second
|
||||
if timeout <= 0 {
|
||||
timeout = 5 * time.Second
|
||||
}
|
||||
return &MiMoService{
|
||||
apiKey: apiKey,
|
||||
model: model,
|
||||
voice: voice,
|
||||
endpoint: endpoint,
|
||||
timeout: timeout,
|
||||
logger: logger,
|
||||
client: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// mimoTTSRequest MiMo TTS API 请求体。
|
||||
type mimoTTSRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []mimoTTSMessage `json:"messages"`
|
||||
Audio mimoTTSAudio `json:"audio"`
|
||||
Stream bool `json:"stream"`
|
||||
}
|
||||
|
||||
// mimoTTSMessage MiMo TTS 消息。
|
||||
type mimoTTSMessage struct {
|
||||
Role string `json:"role"` // "user"(风格指令)| "assistant"(待合成文本)
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// mimoTTSAudio MiMo TTS 音频配置。
|
||||
type mimoTTSAudio struct {
|
||||
Format string `json:"format"` // "mp3" | "wav" | "pcm16"
|
||||
Voice string `json:"voice"` // 预置音色 ID
|
||||
}
|
||||
|
||||
// mimoTTSResponse MiMo TTS 非流式响应。
|
||||
type mimoTTSResponse struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Audio struct {
|
||||
Data string `json:"data"` // base64 编码的音频数据
|
||||
} `json:"audio"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
|
||||
// mimoTTSStreamResponse MiMo TTS 流式响应。
|
||||
type mimoTTSStreamResponse struct {
|
||||
Choices []struct {
|
||||
Delta struct {
|
||||
Audio struct {
|
||||
Data string `json:"data"` // base64 编码的音频数据片段
|
||||
} `json:"audio"`
|
||||
} `json:"delta"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
|
||||
// SynthesizeStream 实现 tts.Service。从 textStream 读取句子,逐句调用 MiMo TTS API。
|
||||
func (m *MiMoService) SynthesizeStream(ctx context.Context, textStream <-chan string, opts Options) (<-chan Chunk, error) {
|
||||
voice := opts.Voice
|
||||
if voice == "" {
|
||||
voice = m.voice
|
||||
}
|
||||
|
||||
ch := make(chan Chunk, 4)
|
||||
go func() {
|
||||
defer close(ch)
|
||||
|
||||
for text := range textStream {
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
audio, err := m.synthesize(ctx, text, voice)
|
||||
if err != nil {
|
||||
m.logger.Warnw("mimo tts: synthesize failed", "error", err, "text", text)
|
||||
// 静默跳过,不中断整个流
|
||||
continue
|
||||
}
|
||||
|
||||
select {
|
||||
case ch <- Chunk{Audio: audio, IsLast: false}:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// textStream 关闭,发送 IsLast 标记
|
||||
select {
|
||||
case ch <- Chunk{Audio: nil, IsLast: true}:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}()
|
||||
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// synthesize 调用 MiMo TTS API 合成单个句子。
|
||||
// 使用非流式调用,返回完整音频数据(base64 解码后)。
|
||||
func (m *MiMoService) synthesize(ctx context.Context, text, voice string) ([]byte, error) {
|
||||
// 单句超时
|
||||
ctx, cancel := context.WithTimeout(ctx, m.timeout)
|
||||
defer cancel()
|
||||
|
||||
// 构建 MiMo TTS 请求:文本放在 assistant 消息中
|
||||
reqBody := mimoTTSRequest{
|
||||
Model: m.model,
|
||||
Messages: []mimoTTSMessage{
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: text,
|
||||
},
|
||||
},
|
||||
Audio: mimoTTSAudio{
|
||||
Format: "mp3",
|
||||
Voice: voice,
|
||||
},
|
||||
Stream: false,
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mimo tts: marshal request: %w", err)
|
||||
}
|
||||
|
||||
url := strings.TrimRight(m.endpoint, "/") + "/chat/completions"
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mimo tts: create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("api-key", m.apiKey)
|
||||
|
||||
resp, err := m.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mimo tts: send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
errBody, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("mimo tts: api error (status %d): %s", resp.StatusCode, string(errBody))
|
||||
}
|
||||
|
||||
// 非流式响应:解析 JSON,提取 base64 音频数据
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mimo tts: read response: %w", err)
|
||||
}
|
||||
|
||||
var ttsResp mimoTTSResponse
|
||||
if err := json.Unmarshal(respBody, &ttsResp); err != nil {
|
||||
return nil, fmt.Errorf("mimo tts: unmarshal response: %w", err)
|
||||
}
|
||||
|
||||
if len(ttsResp.Choices) == 0 {
|
||||
return nil, fmt.Errorf("mimo tts: empty choices in response")
|
||||
}
|
||||
|
||||
audioData := ttsResp.Choices[0].Message.Audio.Data
|
||||
if audioData == "" {
|
||||
return nil, fmt.Errorf("mimo tts: empty audio data in response")
|
||||
}
|
||||
|
||||
// base64 解码音频数据
|
||||
audio, err := base64.StdEncoding.DecodeString(audioData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mimo tts: decode audio base64: %w", err)
|
||||
}
|
||||
|
||||
return audio, nil
|
||||
}
|
||||
405
backend/internal/ai/tts/mimo_test.go
Normal file
405
backend/internal/ai/tts/mimo_test.go
Normal file
@@ -0,0 +1,405 @@
|
||||
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", "", "冰糖", srv.URL, 5, 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 次 API(3 个句子)
|
||||
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", "", "冰糖", srv.URL, 5, 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", "", "冰糖", srv.URL, 1, 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", "", "冰糖", srv.URL, 5, 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", "", "冰糖", srv.URL, 5, 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", "", "冰糖", srv.URL, 5, 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", "", "冰糖", srv.URL, 5, 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", "", "", srv.URL, 5, 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", "", "冰糖", srv.URL, 5, 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user