Files
CamTalk/backend/internal/ai/tts/mimo.go
hhs e967d89e7e feat: 添加 MiMo TTS 语音合成服务
实现基于 Xiaomi MiMo TTS API 的语音合成 provider,使用冰糖音色。
- 新增 MiMoService 实现 tts.Service 接口
- 使用 chat/completions 格式,与 MiMo STT 保持一致
- 在 main.go 添加 TTS provider 切换逻辑(mimo/xiaomi)
- 配置文件默认音色设为冰糖
- 包含完整测试覆盖(9 个测试用例)
2026-06-14 10:05:56 +08:00

213 lines
5.2 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 (
"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
}