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

209 lines
5.2 KiB
Go
Raw Normal View History

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 服务。
// model、voice、endpoint 由 config 层保证非空。
func NewMiMoService(apiKey, model, voice, endpoint string, timeoutSec, httpClientTimeoutSec int, logger *zap.SugaredLogger) *MiMoService {
timeout := time.Duration(timeoutSec) * time.Second
if timeout <= 0 {
timeout = 5 * time.Second
}
httpClientTimeout := time.Duration(httpClientTimeoutSec) * time.Second
if httpClientTimeout <= 0 {
httpClientTimeout = 30 * time.Second
}
return &MiMoService{
apiKey: apiKey,
model: model,
voice: voice,
endpoint: endpoint,
timeout: timeout,
logger: logger,
client: &http.Client{Timeout: httpClientTimeout},
}
}
// 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
}