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
|
||||
}
|
||||
Reference in New Issue
Block a user