add mimo tts

This commit is contained in:
Huaaudio 2026-03-31 16:02:54 +02:00
parent 2471c847d6
commit 1712650e61
3 changed files with 264 additions and 12 deletions

162
pkg/audio/tts/mimo_tts.go Normal file
View file

@ -0,0 +1,162 @@
package tts
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/sipeed/picoclaw/pkg/logger"
)
type MimoTTSProvider struct {
apiKey string
apiBase string
voice string
format string
model string
httpClient *http.Client
}
func NewMimoTTSProvider(apiKey string, apiBase string, model string, proxyURL string) *MimoTTSProvider {
if apiBase == "" {
apiBase = "https://api.xiaomimimo.com/v1/chat/completions"
} else {
if u, err := url.Parse(apiBase); err == nil && u.Scheme != "" && u.Host != "" {
path := u.Path
if u.Host == "api.xiaomimimo.com" {
if path == "" || path == "/" || path == "/v1" || path == "/v1/" {
path = "/v1/chat/completions"
} else {
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
if !strings.HasPrefix(path, "/v1/") {
path = "/v1" + strings.TrimSuffix(path, "/")
}
if !strings.HasSuffix(path, "/chat/completions") {
path = strings.TrimSuffix(path, "/") + "/chat/completions"
}
}
} else {
if !strings.HasSuffix(path, "/chat/completions") {
path = strings.TrimSuffix(path, "/") + "/chat/completions"
}
}
u.Path = path
apiBase = u.String()
} else {
if apiBase == "https://api.xiaomimimo.com/v1" {
apiBase = "https://api.xiaomimimo.com/v1/chat/completions"
} else if !strings.HasSuffix(apiBase, "/chat/completions") {
apiBase = strings.TrimSuffix(apiBase, "/") + "/chat/completions"
}
}
}
model = strings.TrimSpace(model)
if model == "" {
model = "mimo-v2-tts"
}
client := &http.Client{Timeout: 60 * time.Second}
if proxyURL != "" {
if pURL, err := url.Parse(proxyURL); err == nil {
client.Transport = &http.Transport{Proxy: http.ProxyURL(pURL)}
} else {
logger.WarnF(
"NewMimoTTSProvider: invalid proxy URL; proceeding without proxy",
map[string]any{"proxyURL": proxyURL, "error": err},
)
}
}
return &MimoTTSProvider{
apiKey: apiKey,
apiBase: apiBase,
voice: "default_zh", // mimo_default now seems to be an alias for default_en, which is not working for Chinese TTS. default_zh seems to work fine with both English and Chinese, and is likely the intended default for TTS.
format: "mp3",
model: model,
httpClient: client,
}
}
func (t *MimoTTSProvider) Name() string {
return "mimo-tts"
}
func (t *MimoTTSProvider) Synthesize(ctx context.Context, text string) (io.ReadCloser, error) {
logger.DebugCF("voice-tts", "Starting TTS synthesis", map[string]any{"text_len": len(text), "provider": t.Name()})
reqBody := map[string]any{
"model": t.model,
"messages": []map[string]string{
{"role": "assistant", "content": text},
},
"audio": map[string]string{
"format": t.format,
"voice": t.voice,
},
"stream": false,
}
jsonData, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", t.apiBase, bytes.NewReader(jsonData))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Api-Key", t.apiKey)
resp, err := t.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
}
var payload struct {
Choices []struct {
Message struct {
Audio struct {
Data string `json:"data"`
} `json:"audio"`
} `json:"message"`
} `json:"choices"`
}
err = json.Unmarshal(body, &payload)
if err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
if len(payload.Choices) == 0 || payload.Choices[0].Message.Audio.Data == "" {
return nil, fmt.Errorf("invalid TTS response: missing audio data")
}
audioBytes, err := base64.StdEncoding.DecodeString(payload.Choices[0].Message.Audio.Data)
if err != nil {
return nil, fmt.Errorf("failed to decode audio data: %w", err)
}
return io.NopCloser(bytes.NewReader(audioBytes)), nil
}

View file

@ -24,12 +24,17 @@ func providerFromModelConfig(mc *config.ModelConfig) TTSProvider {
return nil return nil
} }
_, modelID := providers.ExtractProtocol(mc.Model) protocol, modelID := providers.ExtractProtocol(mc.Model)
if modelID == "" { if modelID == "" {
modelID = strings.TrimSpace(mc.Model) modelID = strings.TrimSpace(mc.Model)
} }
switch protocol {
case "mimo":
return NewMimoTTSProvider(mc.APIKey(), providers.ResolveAPIBase(mc), modelID, mc.Proxy)
default:
return NewOpenAITTSProvider(mc.APIKey(), providers.ResolveAPIBase(mc), mc.Proxy, modelID) return NewOpenAITTSProvider(mc.APIKey(), providers.ResolveAPIBase(mc), mc.Proxy, modelID)
}
} }
func DetectTTS(cfg *config.Config) TTSProvider { func DetectTTS(cfg *config.Config) TTSProvider {
@ -89,7 +94,14 @@ func SynthesizeAndStore(
return "", fmt.Errorf("failed to create media temp dir: %w", err) return "", fmt.Errorf("failed to create media temp dir: %w", err)
} }
file, err := os.CreateTemp(media.TempDir(), "tts-*.ogg") fileExt := ".ogg"
contentType := "audio/ogg"
if provider.Name() == "mimo-tts" {
fileExt = ".mp3"
contentType = "audio/mpeg"
}
file, err := os.CreateTemp(media.TempDir(), "tts-*"+fileExt)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to create temp file: %w", err) return "", fmt.Errorf("failed to create temp file: %w", err)
} }
@ -114,20 +126,20 @@ func SynthesizeAndStore(
filename = strings.TrimSpace(filename) filename = strings.TrimSpace(filename)
if filename == "" { if filename == "" {
filename = fmt.Sprintf("tts-%d.ogg", time.Now().Unix()) filename = fmt.Sprintf("tts-%d%s", time.Now().Unix(), fileExt)
} }
ext := strings.ToLower(filepath.Ext(filename)) ext := strings.ToLower(filepath.Ext(filename))
if ext == "" { if ext == "" {
filename += ".ogg" filename += fileExt
} else if ext != ".ogg" { } else if ext != fileExt {
filename = strings.TrimSuffix(filename, filepath.Ext(filename)) + ".ogg" filename = strings.TrimSuffix(filename, filepath.Ext(filename)) + fileExt
} }
scope := fmt.Sprintf("tool:send_tts:%s:%s:%d", channel, chatID, time.Now().UnixNano()) scope := fmt.Sprintf("tool:send_tts:%s:%s:%d", channel, chatID, time.Now().UnixNano())
ref, err := store.Store(file.Name(), media.MediaMeta{ ref, err := store.Store(file.Name(), media.MediaMeta{
Filename: filename, Filename: filename,
ContentType: "audio/ogg", ContentType: contentType,
Source: "tool:send_tts", Source: "tool:send_tts",
}, scope) }, scope)
if err != nil { if err != nil {

View file

@ -6,10 +6,12 @@ import (
"io" "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"path/filepath"
"strings" "strings"
"testing" "testing"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/media"
) )
func TestNewOpenAITTSProvider_APIBaseNormalization(t *testing.T) { func TestNewOpenAITTSProvider_APIBaseNormalization(t *testing.T) {
@ -142,7 +144,7 @@ func TestNewOpenAITTSProvider_UsesConfiguredModel(t *testing.T) {
} }
} }
func TestDetectTTS_UsesConfiguredModelAndProviderBase(t *testing.T) { func TestDetectTTS_UsesMimoProviderForMimoModels(t *testing.T) {
t.Parallel() t.Parallel()
provider := DetectTTS(&config.Config{ provider := DetectTTS(&config.Config{
@ -156,14 +158,90 @@ func TestDetectTTS_UsesConfiguredModelAndProviderBase(t *testing.T) {
}, },
}) })
ttsProvider, ok := provider.(*OpenAITTSProvider) ttsProvider, ok := provider.(*MimoTTSProvider)
if !ok { if !ok {
t.Fatalf("DetectTTS() type = %T, want *OpenAITTSProvider", provider) t.Fatalf("DetectTTS() type = %T, want *MimoTTSProvider", provider)
} }
if ttsProvider.model != "mimo-v2-tts" { if ttsProvider.model != "mimo-v2-tts" {
t.Fatalf("model mismatch: got %q, want %q", ttsProvider.model, "mimo-v2-tts") t.Fatalf("model mismatch: got %q, want %q", ttsProvider.model, "mimo-v2-tts")
} }
if ttsProvider.apiBase != "https://api.xiaomimimo.com/v1/audio/speech" { if ttsProvider.apiBase != "https://api.xiaomimimo.com/v1/chat/completions" {
t.Fatalf("apiBase mismatch: got %q", ttsProvider.apiBase) t.Fatalf("apiBase mismatch: got %q", ttsProvider.apiBase)
} }
} }
type stubTTSProvider struct {
name string
}
func (s stubTTSProvider) Name() string {
return s.name
}
func (s stubTTSProvider) Synthesize(ctx context.Context, text string) (io.ReadCloser, error) {
return io.NopCloser(strings.NewReader("audio")), nil
}
func TestSynthesizeAndStore_UsesOggMetadataByDefault(t *testing.T) {
t.Parallel()
store := media.NewFileMediaStore()
ref, err := SynthesizeAndStore(
context.Background(),
stubTTSProvider{name: "openai-tts"},
store,
"hello",
"",
"discord",
"chat123",
)
if err != nil {
t.Fatalf("SynthesizeAndStore failed: %v", err)
}
path, meta, err := store.ResolveWithMeta(ref)
if err != nil {
t.Fatalf("ResolveWithMeta failed: %v", err)
}
if meta.ContentType != "audio/ogg" {
t.Fatalf("ContentType = %q, want %q", meta.ContentType, "audio/ogg")
}
if filepath.Ext(path) != ".ogg" {
t.Fatalf("stored file extension = %q, want %q", filepath.Ext(path), ".ogg")
}
if filepath.Ext(meta.Filename) != ".ogg" {
t.Fatalf("filename extension = %q, want %q", filepath.Ext(meta.Filename), ".ogg")
}
}
func TestSynthesizeAndStore_UsesMp3MetadataForMimo(t *testing.T) {
t.Parallel()
store := media.NewFileMediaStore()
ref, err := SynthesizeAndStore(
context.Background(),
stubTTSProvider{name: "mimo-tts"},
store,
"hello",
"",
"discord",
"chat123",
)
if err != nil {
t.Fatalf("SynthesizeAndStore failed: %v", err)
}
path, meta, err := store.ResolveWithMeta(ref)
if err != nil {
t.Fatalf("ResolveWithMeta failed: %v", err)
}
if meta.ContentType != "audio/mpeg" {
t.Fatalf("ContentType = %q, want %q", meta.ContentType, "audio/mpeg")
}
if filepath.Ext(path) != ".mp3" {
t.Fatalf("stored file extension = %q, want %q", filepath.Ext(path), ".mp3")
}
if filepath.Ext(meta.Filename) != ".mp3" {
t.Fatalf("filename extension = %q, want %q", filepath.Ext(meta.Filename), ".mp3")
}
}