mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-17 18:14:13 +00:00
refactor(tts): replace per-provider files with 24-symbol alias layer
internal/tts becomes a thin backward-compat alias over internal/audio: 15 type aliases, 6 constants, 5 constructors, 5 compile-time signature guards. All pre-refactor callers compile unchanged. alias_test.go enforces symbol coverage and type identity. Old per-provider files (manager, types, elevenlabs, openai, edge, minimax) are removed in the same commit to keep history bisectable.
This commit is contained in:
1 parent
f4cc595e50
commit
214bd83bde
8 files changed
+156
-778
No files matched your search
@@ -0,0 +1,74 @@
|
||||
// Package tts is a thin backward-compat alias over internal/audio.
|
||||
//
|
||||
// The real implementation lives in internal/audio (TTS + STT + Music + SFX
|
||||
// unified manager). This file preserves the legacy 24-symbol surface so that
|
||||
// pre-Phase-1 callers continue to compile unchanged. New code should import
|
||||
// internal/audio directly — the aliases here will be removed after all
|
||||
// callers migrate.
|
||||
//
|
||||
// Contract: every exported symbol that existed in the pre-Phase-1 tts package
|
||||
// has an alias or constructor below. If you add a new exported symbol to
|
||||
// internal/audio that previously lived in tts, also add it here — and to the
|
||||
// alias_test.go symbol-coverage test.
|
||||
package tts
|
||||
|
||||
import (
|
||||
"github.com/nextlevelbuilder/goclaw/internal/audio"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/audio/edge"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/audio/elevenlabs"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/audio/minimax"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/audio/openai"
|
||||
)
|
||||
|
||||
// --- Types (15) ---
|
||||
|
||||
type Manager = audio.Manager
|
||||
type Provider = audio.TTSProvider
|
||||
type Options = audio.TTSOptions
|
||||
type SynthResult = audio.SynthResult
|
||||
type AutoMode = audio.AutoMode
|
||||
type Mode = audio.Mode
|
||||
type ManagerConfig = audio.ManagerConfig
|
||||
type EdgeConfig = edge.Config
|
||||
type EdgeProvider = edge.Provider
|
||||
type ElevenLabsConfig = elevenlabs.Config
|
||||
type ElevenLabsProvider = elevenlabs.TTSProvider
|
||||
type MiniMaxConfig = minimax.Config
|
||||
type MiniMaxProvider = minimax.Provider
|
||||
type OpenAIConfig = openai.Config
|
||||
type OpenAIProvider = openai.Provider
|
||||
|
||||
// --- Constants (6) ---
|
||||
|
||||
const (
|
||||
AutoOff = audio.AutoOff
|
||||
AutoAlways = audio.AutoAlways
|
||||
AutoInbound = audio.AutoInbound
|
||||
AutoTagged = audio.AutoTagged
|
||||
ModeFinal = audio.ModeFinal
|
||||
ModeAll = audio.ModeAll
|
||||
)
|
||||
|
||||
// --- Constructors (5) ---
|
||||
|
||||
var (
|
||||
NewManager = audio.NewManager
|
||||
NewEdgeProvider = edge.NewProvider
|
||||
NewElevenLabsProvider = elevenlabs.NewTTSProvider
|
||||
NewMiniMaxProvider = minimax.NewProvider
|
||||
NewOpenAIProvider = openai.NewProvider
|
||||
)
|
||||
|
||||
// --- Compile-time signature guards (5) ---
|
||||
//
|
||||
// These catch silent argument-type divergence when an audio.* constructor is
|
||||
// refactored. Go's package-level `var x = fn` elides signature checks — these
|
||||
// explicit typed assignments restore them.
|
||||
|
||||
var (
|
||||
_ func(audio.ManagerConfig) *audio.Manager = NewManager
|
||||
_ func(edge.Config) *edge.Provider = NewEdgeProvider
|
||||
_ func(elevenlabs.Config) *elevenlabs.TTSProvider = NewElevenLabsProvider
|
||||
_ func(minimax.Config) *minimax.Provider = NewMiniMaxProvider
|
||||
_ func(openai.Config) *openai.Provider = NewOpenAIProvider
|
||||
)
|
||||
@@ -0,0 +1,82 @@
|
||||
package tts_test
|
||||
|
||||
// Symbol-coverage + alias identity test for internal/tts.
|
||||
//
|
||||
// Why a _test.go file (not a compile-time guard in alias.go)? Guards in alias.go
|
||||
// are mandatory for signatures; this test additionally proves every callable
|
||||
// symbol in the former tts package still resolves after the alias layer lands.
|
||||
// If a rare exported symbol is forgotten in alias.go, this file fails to
|
||||
// compile — fastest possible feedback loop.
|
||||
//
|
||||
// The test references every alias target via the tts.* package name. Adding a
|
||||
// new exported symbol to internal/audio requires adding both an alias in
|
||||
// alias.go AND a reference here.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"unsafe"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/audio"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/audio/edge"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/audio/elevenlabs"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/audio/minimax"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/audio/openai"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/tts"
|
||||
)
|
||||
|
||||
// --- Types (15) — alias identity checks (both sides must be exact same type) ---
|
||||
|
||||
var (
|
||||
_ *audio.Manager = (*tts.Manager)(nil)
|
||||
_ audio.TTSProvider = (tts.Provider)(nil)
|
||||
_ *audio.TTSOptions = (*tts.Options)(nil)
|
||||
_ *audio.SynthResult = (*tts.SynthResult)(nil)
|
||||
_ audio.AutoMode = tts.AutoMode("")
|
||||
_ audio.Mode = tts.Mode("")
|
||||
_ *audio.ManagerConfig = (*tts.ManagerConfig)(nil)
|
||||
_ *edge.Config = (*tts.EdgeConfig)(nil)
|
||||
_ *edge.Provider = (*tts.EdgeProvider)(nil)
|
||||
_ *elevenlabs.Config = (*tts.ElevenLabsConfig)(nil)
|
||||
_ *elevenlabs.TTSProvider = (*tts.ElevenLabsProvider)(nil)
|
||||
_ *minimax.Config = (*tts.MiniMaxConfig)(nil)
|
||||
_ *minimax.Provider = (*tts.MiniMaxProvider)(nil)
|
||||
_ *openai.Config = (*tts.OpenAIConfig)(nil)
|
||||
_ *openai.Provider = (*tts.OpenAIProvider)(nil)
|
||||
)
|
||||
|
||||
// --- Constants (6) ---
|
||||
|
||||
var (
|
||||
_ = tts.AutoOff
|
||||
_ = tts.AutoAlways
|
||||
_ = tts.AutoInbound
|
||||
_ = tts.AutoTagged
|
||||
_ = tts.ModeFinal
|
||||
_ = tts.ModeAll
|
||||
)
|
||||
|
||||
// --- Constructors (5) ---
|
||||
|
||||
var (
|
||||
_ = tts.NewManager
|
||||
_ = tts.NewEdgeProvider
|
||||
_ = tts.NewElevenLabsProvider
|
||||
_ = tts.NewMiniMaxProvider
|
||||
_ = tts.NewOpenAIProvider
|
||||
)
|
||||
|
||||
// TestAliasIdentity proves at runtime that alias targets resolve to the
|
||||
// underlying audio types — guard against accidental "replica type" divergence.
|
||||
func TestAliasIdentity(t *testing.T) {
|
||||
if unsafe.Sizeof(tts.Options{}) != unsafe.Sizeof(audio.TTSOptions{}) {
|
||||
t.Fatalf("tts.Options size mismatch: got %d, want %d",
|
||||
unsafe.Sizeof(tts.Options{}), unsafe.Sizeof(audio.TTSOptions{}))
|
||||
}
|
||||
mgr := tts.NewManager(tts.ManagerConfig{Primary: "none"})
|
||||
if mgr == nil {
|
||||
t.Fatal("tts.NewManager returned nil")
|
||||
}
|
||||
// Exercise interface method set end-to-end to prove Provider alias works.
|
||||
var _ func(context.Context, string, tts.Options) (*tts.SynthResult, error) = mgr.Synthesize
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
package tts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// EdgeProvider implements TTS via Microsoft Edge TTS (free, no API key).
|
||||
// Matching TS edgeTTS() in src/tts/tts-core.ts.
|
||||
// Requires the `edge-tts` CLI tool to be installed:
|
||||
//
|
||||
// pip install edge-tts
|
||||
type EdgeProvider struct {
|
||||
voice string // default "en-US-MichelleNeural"
|
||||
rate string // speech rate, e.g. "+0%"
|
||||
timeoutMs int
|
||||
}
|
||||
|
||||
// EdgeConfig configures the Edge TTS provider.
|
||||
type EdgeConfig struct {
|
||||
Voice string
|
||||
Rate string
|
||||
TimeoutMs int
|
||||
}
|
||||
|
||||
// NewEdgeProvider creates an Edge TTS provider.
|
||||
func NewEdgeProvider(cfg EdgeConfig) *EdgeProvider {
|
||||
p := &EdgeProvider{
|
||||
voice: cfg.Voice,
|
||||
rate: cfg.Rate,
|
||||
timeoutMs: cfg.TimeoutMs,
|
||||
}
|
||||
if p.voice == "" {
|
||||
p.voice = "en-US-MichelleNeural"
|
||||
}
|
||||
if p.timeoutMs <= 0 {
|
||||
p.timeoutMs = 30000
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *EdgeProvider) Name() string { return "edge" }
|
||||
|
||||
// Synthesize runs the edge-tts CLI to generate audio.
|
||||
// Output is always MP3 (edge-tts default format: audio-24khz-48kbitrate-mono-mp3).
|
||||
func (p *EdgeProvider) Synthesize(ctx context.Context, text string, _ Options) (*SynthResult, error) {
|
||||
// Create temp file for output
|
||||
tmpDir := os.TempDir()
|
||||
outPath := filepath.Join(tmpDir, fmt.Sprintf("tts-%d.mp3", time.Now().UnixNano()))
|
||||
defer os.Remove(outPath)
|
||||
|
||||
args := []string{
|
||||
"--voice", p.voice,
|
||||
"--text", text,
|
||||
"--write-media", outPath,
|
||||
}
|
||||
if p.rate != "" {
|
||||
args = append(args, "--rate", p.rate)
|
||||
}
|
||||
|
||||
timeout := time.Duration(p.timeoutMs) * time.Millisecond
|
||||
cmdCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(cmdCtx, "edge-tts", args...)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
return nil, fmt.Errorf("edge-tts failed: %w (output: %s)", err, string(output))
|
||||
}
|
||||
|
||||
audio, err := os.ReadFile(outPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read edge-tts output: %w", err)
|
||||
}
|
||||
|
||||
return &SynthResult{
|
||||
Audio: audio,
|
||||
Extension: "mp3",
|
||||
MimeType: "audio/mpeg",
|
||||
}, nil
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
package tts
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ElevenLabsProvider implements TTS via the ElevenLabs API.
|
||||
// Matching TS elevenLabsTTS() in src/tts/tts-core.ts.
|
||||
type ElevenLabsProvider struct {
|
||||
apiKey string
|
||||
baseURL string
|
||||
voiceID string // default "pMsXgVXv3BLzUgSXRplE"
|
||||
modelID string // default "eleven_multilingual_v2"
|
||||
timeoutMs int
|
||||
}
|
||||
|
||||
// ElevenLabsConfig configures the ElevenLabs TTS provider.
|
||||
type ElevenLabsConfig struct {
|
||||
APIKey string
|
||||
BaseURL string
|
||||
VoiceID string
|
||||
ModelID string
|
||||
TimeoutMs int
|
||||
}
|
||||
|
||||
// NewElevenLabsProvider creates an ElevenLabs TTS provider.
|
||||
func NewElevenLabsProvider(cfg ElevenLabsConfig) *ElevenLabsProvider {
|
||||
p := &ElevenLabsProvider{
|
||||
apiKey: cfg.APIKey,
|
||||
baseURL: cfg.BaseURL,
|
||||
voiceID: cfg.VoiceID,
|
||||
modelID: cfg.ModelID,
|
||||
timeoutMs: cfg.TimeoutMs,
|
||||
}
|
||||
if p.baseURL == "" {
|
||||
p.baseURL = "https://api.elevenlabs.io"
|
||||
}
|
||||
if p.voiceID == "" {
|
||||
p.voiceID = "pMsXgVXv3BLzUgSXRplE"
|
||||
}
|
||||
if p.modelID == "" {
|
||||
p.modelID = "eleven_multilingual_v2"
|
||||
}
|
||||
if p.timeoutMs <= 0 {
|
||||
p.timeoutMs = 30000
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *ElevenLabsProvider) Name() string { return "elevenlabs" }
|
||||
|
||||
// Synthesize calls the ElevenLabs text-to-speech endpoint.
|
||||
// Matching TS: POST {baseUrl}/v1/text-to-speech/{voiceId}.
|
||||
func (p *ElevenLabsProvider) Synthesize(ctx context.Context, text string, opts Options) (*SynthResult, error) {
|
||||
voiceID := opts.Voice
|
||||
if voiceID == "" {
|
||||
voiceID = p.voiceID
|
||||
}
|
||||
modelID := opts.Model
|
||||
if modelID == "" {
|
||||
modelID = p.modelID
|
||||
}
|
||||
|
||||
// Determine output format
|
||||
outputFormat := "mp3_44100_128"
|
||||
ext := "mp3"
|
||||
mime := "audio/mpeg"
|
||||
if opts.Format == "opus" {
|
||||
outputFormat = "opus_48000_64"
|
||||
ext = "ogg"
|
||||
mime = "audio/ogg"
|
||||
}
|
||||
|
||||
body := map[string]any{
|
||||
"text": text,
|
||||
"model_id": modelID,
|
||||
"voice_settings": map[string]any{
|
||||
"stability": 0.5,
|
||||
"similarity_boost": 0.75,
|
||||
"style": 0.0,
|
||||
"use_speaker_boost": true,
|
||||
},
|
||||
}
|
||||
|
||||
bodyJSON, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal elevenlabs tts request: %w", err)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/v1/text-to-speech/%s?output_format=%s", p.baseURL, voiceID, outputFormat)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyJSON))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create elevenlabs tts request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("xi-api-key", p.apiKey)
|
||||
|
||||
client := &http.Client{Timeout: time.Duration(p.timeoutMs) * time.Millisecond}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("elevenlabs tts request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
errBody, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("elevenlabs tts error %d: %s", resp.StatusCode, string(errBody))
|
||||
}
|
||||
|
||||
audio, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read elevenlabs tts response: %w", err)
|
||||
}
|
||||
|
||||
return &SynthResult{
|
||||
Audio: audio,
|
||||
Extension: ext,
|
||||
MimeType: mime,
|
||||
}, nil
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
package tts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Manager orchestrates TTS providers and auto-apply logic.
|
||||
// Matching TS src/tts/tts.ts maybeApplyTtsToPayload().
|
||||
type Manager struct {
|
||||
providers map[string]Provider
|
||||
primary string // primary provider name
|
||||
auto AutoMode // auto-apply mode
|
||||
mode Mode // "final" or "all"
|
||||
maxLength int // max text length before truncation (default 1500)
|
||||
timeoutMs int // provider timeout (default 30000)
|
||||
}
|
||||
|
||||
// ManagerConfig configures the TTS manager.
|
||||
type ManagerConfig struct {
|
||||
Primary string // primary provider name
|
||||
Auto AutoMode // auto-apply mode (default "off")
|
||||
Mode Mode // "final" or "all" (default "final")
|
||||
MaxLength int // default 1500
|
||||
TimeoutMs int // default 30000
|
||||
}
|
||||
|
||||
// NewManager creates a TTS manager.
|
||||
func NewManager(cfg ManagerConfig) *Manager {
|
||||
m := &Manager{
|
||||
providers: make(map[string]Provider),
|
||||
primary: cfg.Primary,
|
||||
auto: cfg.Auto,
|
||||
mode: cfg.Mode,
|
||||
maxLength: cfg.MaxLength,
|
||||
timeoutMs: cfg.TimeoutMs,
|
||||
}
|
||||
if m.auto == "" {
|
||||
m.auto = AutoOff
|
||||
}
|
||||
if m.mode == "" {
|
||||
m.mode = ModeFinal
|
||||
}
|
||||
if m.maxLength <= 0 {
|
||||
m.maxLength = 1500
|
||||
}
|
||||
if m.timeoutMs <= 0 {
|
||||
m.timeoutMs = 30000
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// RegisterProvider adds a TTS provider.
|
||||
func (m *Manager) RegisterProvider(p Provider) {
|
||||
m.providers[p.Name()] = p
|
||||
// If no primary set, use first registered
|
||||
if m.primary == "" {
|
||||
m.primary = p.Name()
|
||||
}
|
||||
}
|
||||
|
||||
// GetProvider returns a provider by name.
|
||||
func (m *Manager) GetProvider(name string) (Provider, bool) {
|
||||
p, ok := m.providers[name]
|
||||
return p, ok
|
||||
}
|
||||
|
||||
// PrimaryProvider returns the primary provider name.
|
||||
func (m *Manager) PrimaryProvider() string { return m.primary }
|
||||
|
||||
// AutoMode returns the current auto-apply mode.
|
||||
func (m *Manager) AutoMode() AutoMode { return m.auto }
|
||||
|
||||
// Synthesize converts text to audio using the specified or primary provider.
|
||||
func (m *Manager) Synthesize(ctx context.Context, text string, opts Options) (*SynthResult, error) {
|
||||
providerName := m.primary
|
||||
if opts.Voice != "" || opts.Model != "" {
|
||||
// Opts might imply a specific provider — stick with primary for now
|
||||
}
|
||||
|
||||
p, ok := m.providers[providerName]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("tts provider not found: %s", providerName)
|
||||
}
|
||||
|
||||
return p.Synthesize(ctx, text, opts)
|
||||
}
|
||||
|
||||
// SynthesizeWithFallback tries the primary provider, then falls back to others.
|
||||
// Matching TS resolveTtsProviderOrder().
|
||||
func (m *Manager) SynthesizeWithFallback(ctx context.Context, text string, opts Options) (*SynthResult, error) {
|
||||
// Try primary first
|
||||
if p, ok := m.providers[m.primary]; ok {
|
||||
result, err := p.Synthesize(ctx, text, opts)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
slog.Warn("tts primary provider failed, trying fallback", "provider", m.primary, "error", err)
|
||||
}
|
||||
|
||||
// Try other providers
|
||||
for name, p := range m.providers {
|
||||
if name == m.primary {
|
||||
continue
|
||||
}
|
||||
result, err := p.Synthesize(ctx, text, opts)
|
||||
if err == nil {
|
||||
slog.Info("tts fallback succeeded", "provider", name)
|
||||
return result, nil
|
||||
}
|
||||
slog.Warn("tts fallback provider failed", "provider", name, "error", err)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("all tts providers failed")
|
||||
}
|
||||
|
||||
// MaybeApply checks auto-mode and conditionally applies TTS to a reply text.
|
||||
// Returns (audioBytes, extension, applied). If not applied, returns (nil, "", false).
|
||||
// Matching TS maybeApplyTtsToPayload().
|
||||
//
|
||||
// Parameters:
|
||||
// - text: the reply text to potentially convert
|
||||
// - channel: origin channel (affects output format, e.g. "telegram" → opus)
|
||||
// - isVoiceInbound: whether the user's message was audio/voice
|
||||
// - kind: "tool", "block", or "final"
|
||||
func (m *Manager) MaybeApply(ctx context.Context, text, channel string, isVoiceInbound bool, kind string) (*SynthResult, bool) {
|
||||
if m.auto == AutoOff {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Mode filter: "final" mode skips tool/block
|
||||
if m.mode == ModeFinal && (kind == "tool" || kind == "block") {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Auto-mode check
|
||||
switch m.auto {
|
||||
case AutoInbound:
|
||||
if !isVoiceInbound {
|
||||
return nil, false
|
||||
}
|
||||
case AutoTagged:
|
||||
if !strings.Contains(text, "[[tts]]") && !strings.Contains(text, "[[tts:") {
|
||||
return nil, false
|
||||
}
|
||||
case AutoAlways:
|
||||
// Always apply
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Content validation (matching TS checks)
|
||||
cleanText := stripMarkdown(text)
|
||||
cleanText = stripTtsDirectives(cleanText)
|
||||
cleanText = strings.TrimSpace(cleanText)
|
||||
|
||||
if len(cleanText) < 10 {
|
||||
return nil, false
|
||||
}
|
||||
if strings.Contains(cleanText, "MEDIA:") {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Truncate if over max length
|
||||
if len(cleanText) > m.maxLength {
|
||||
cleanText = cleanText[:m.maxLength] + "..."
|
||||
}
|
||||
|
||||
// Determine format based on channel
|
||||
opts := Options{}
|
||||
if channel == "telegram" {
|
||||
opts.Format = "opus" // Telegram voice bubbles need opus
|
||||
}
|
||||
|
||||
result, err := m.SynthesizeWithFallback(ctx, cleanText, opts)
|
||||
if err != nil {
|
||||
slog.Warn("tts auto-apply failed", "error", err)
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return result, true
|
||||
}
|
||||
|
||||
// HasProviders returns true if at least one provider is registered.
|
||||
func (m *Manager) HasProviders() bool {
|
||||
return len(m.providers) > 0
|
||||
}
|
||||
|
||||
// --- Text processing helpers ---
|
||||
|
||||
// stripMarkdown removes common markdown formatting for cleaner TTS input.
|
||||
func stripMarkdown(text string) string {
|
||||
// Remove code blocks entirely
|
||||
text = regexp.MustCompile("(?s)```[^`]*```").ReplaceAllString(text, "")
|
||||
// Remove inline code
|
||||
text = regexp.MustCompile("`([^`]+)`").ReplaceAllString(text, "$1")
|
||||
// Bold/italic → content only
|
||||
text = regexp.MustCompile("\\*\\*([^*]+)\\*\\*").ReplaceAllString(text, "$1")
|
||||
text = regexp.MustCompile("\\*([^*]+)\\*").ReplaceAllString(text, "$1")
|
||||
text = regexp.MustCompile("__([^_]+)__").ReplaceAllString(text, "$1")
|
||||
text = regexp.MustCompile("_([^_]+)_").ReplaceAllString(text, "$1")
|
||||
// Links → text only
|
||||
text = regexp.MustCompile("\\[([^\\]]+)\\]\\([^)]+\\)").ReplaceAllString(text, "$1")
|
||||
// Headers → content
|
||||
text = regexp.MustCompile("(?m)^#+\\s+").ReplaceAllString(text, "")
|
||||
return text
|
||||
}
|
||||
|
||||
// stripTtsDirectives removes [[tts...]] directives from text.
|
||||
// Matching TS parseTtsDirectives().
|
||||
func stripTtsDirectives(text string) string {
|
||||
// Remove [[tts:text]]...[[/tts:text]] blocks (keep inner text)
|
||||
text = regexp.MustCompile(`(?s)\[\[tts:text\]\](.*?)\[\[/tts:text\]\]`).ReplaceAllString(text, "$1")
|
||||
// Remove [[tts]] and [[tts:...]] tags
|
||||
text = regexp.MustCompile(`\[\[tts(?::[^\]]*)?\]\]`).ReplaceAllString(text, "")
|
||||
return text
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
package tts
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MiniMaxProvider implements TTS via the MiniMax T2A API.
|
||||
// Docs: https://platform.minimax.io/docs/api-reference/speech-t2a-intro
|
||||
//
|
||||
// Supports 300+ system voices, 40+ languages, multiple output formats.
|
||||
// Models: speech-02-hd (high quality), speech-02-turbo (fast).
|
||||
type MiniMaxProvider struct {
|
||||
apiKey string
|
||||
groupID string // MiniMax GroupId (required)
|
||||
apiBase string // default "https://api.minimax.io/v1"
|
||||
model string // default "speech-02-hd"
|
||||
voiceID string // default "Wise_Woman"
|
||||
timeoutMs int
|
||||
}
|
||||
|
||||
// MiniMaxConfig configures the MiniMax TTS provider.
|
||||
type MiniMaxConfig struct {
|
||||
APIKey string
|
||||
GroupID string
|
||||
APIBase string
|
||||
Model string
|
||||
VoiceID string
|
||||
TimeoutMs int
|
||||
}
|
||||
|
||||
// NewMiniMaxProvider creates a MiniMax TTS provider.
|
||||
func NewMiniMaxProvider(cfg MiniMaxConfig) *MiniMaxProvider {
|
||||
p := &MiniMaxProvider{
|
||||
apiKey: cfg.APIKey,
|
||||
groupID: cfg.GroupID,
|
||||
apiBase: cfg.APIBase,
|
||||
model: cfg.Model,
|
||||
voiceID: cfg.VoiceID,
|
||||
timeoutMs: cfg.TimeoutMs,
|
||||
}
|
||||
if p.apiBase == "" {
|
||||
p.apiBase = "https://api.minimax.io/v1"
|
||||
}
|
||||
if p.model == "" {
|
||||
p.model = "speech-02-hd"
|
||||
}
|
||||
if p.voiceID == "" {
|
||||
p.voiceID = "Wise_Woman"
|
||||
}
|
||||
if p.timeoutMs <= 0 {
|
||||
p.timeoutMs = 30000
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *MiniMaxProvider) Name() string { return "minimax" }
|
||||
|
||||
// Synthesize calls the MiniMax T2A v2 endpoint (non-streaming).
|
||||
// Response audio is returned as hex-encoded bytes in response.data.audio.
|
||||
func (p *MiniMaxProvider) Synthesize(ctx context.Context, text string, opts Options) (*SynthResult, error) {
|
||||
voiceID := opts.Voice
|
||||
if voiceID == "" {
|
||||
voiceID = p.voiceID
|
||||
}
|
||||
model := opts.Model
|
||||
if model == "" {
|
||||
model = p.model
|
||||
}
|
||||
|
||||
// Determine output format
|
||||
audioFormat := "mp3"
|
||||
ext := "mp3"
|
||||
mime := "audio/mpeg"
|
||||
if opts.Format == "opus" || opts.Format == "pcm" || opts.Format == "flac" || opts.Format == "wav" {
|
||||
audioFormat = opts.Format
|
||||
switch opts.Format {
|
||||
case "pcm":
|
||||
ext = "pcm"
|
||||
mime = "audio/pcm"
|
||||
case "flac":
|
||||
ext = "flac"
|
||||
mime = "audio/flac"
|
||||
case "wav":
|
||||
ext = "wav"
|
||||
mime = "audio/wav"
|
||||
}
|
||||
}
|
||||
|
||||
body := map[string]any{
|
||||
"text": text,
|
||||
"model": model,
|
||||
"stream": false,
|
||||
"voice_setting": map[string]any{
|
||||
"voice_id": voiceID,
|
||||
"speed": 1.0,
|
||||
"pitch": 0,
|
||||
},
|
||||
"audio_setting": map[string]any{
|
||||
"format": audioFormat,
|
||||
},
|
||||
}
|
||||
|
||||
bodyJSON, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal minimax tts request: %w", err)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/t2a_v2?GroupId=%s", p.apiBase, p.groupID)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyJSON))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create minimax tts request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
||||
|
||||
client := &http.Client{Timeout: time.Duration(p.timeoutMs) * time.Millisecond}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("minimax tts request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read minimax tts response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("minimax tts error %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
// Parse response: { base_resp: {status_code}, data: {audio: "hex..."} }
|
||||
var apiResp miniMaxResponse
|
||||
if err := json.Unmarshal(respBody, &apiResp); err != nil {
|
||||
return nil, fmt.Errorf("parse minimax tts response: %w", err)
|
||||
}
|
||||
|
||||
if apiResp.BaseResp.StatusCode != 0 {
|
||||
return nil, fmt.Errorf("minimax tts api error %d: %s", apiResp.BaseResp.StatusCode, apiResp.BaseResp.StatusMsg)
|
||||
}
|
||||
|
||||
if apiResp.Data.Audio == "" {
|
||||
return nil, fmt.Errorf("minimax tts returned empty audio")
|
||||
}
|
||||
|
||||
// Decode hex-encoded audio
|
||||
audio, err := hex.DecodeString(apiResp.Data.Audio)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode minimax tts audio hex: %w", err)
|
||||
}
|
||||
|
||||
return &SynthResult{
|
||||
Audio: audio,
|
||||
Extension: ext,
|
||||
MimeType: mime,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type miniMaxResponse struct {
|
||||
BaseResp struct {
|
||||
StatusCode int `json:"status_code"`
|
||||
StatusMsg string `json:"status_msg"`
|
||||
} `json:"base_resp"`
|
||||
Data struct {
|
||||
Audio string `json:"audio"` // hex-encoded audio bytes
|
||||
} `json:"data"`
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
package tts
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// OpenAIProvider implements TTS via the OpenAI audio/speech API.
|
||||
// Matching TS openaiTTS() in src/tts/tts-core.ts.
|
||||
type OpenAIProvider struct {
|
||||
apiKey string
|
||||
apiBase string
|
||||
model string // default "gpt-4o-mini-tts"
|
||||
voice string // default "alloy"
|
||||
timeoutMs int // default 30000
|
||||
}
|
||||
|
||||
// OpenAIConfig configures the OpenAI TTS provider.
|
||||
type OpenAIConfig struct {
|
||||
APIKey string
|
||||
APIBase string
|
||||
Model string
|
||||
Voice string
|
||||
TimeoutMs int
|
||||
}
|
||||
|
||||
// NewOpenAIProvider creates an OpenAI TTS provider.
|
||||
func NewOpenAIProvider(cfg OpenAIConfig) *OpenAIProvider {
|
||||
p := &OpenAIProvider{
|
||||
apiKey: cfg.APIKey,
|
||||
apiBase: cfg.APIBase,
|
||||
model: cfg.Model,
|
||||
voice: cfg.Voice,
|
||||
timeoutMs: cfg.TimeoutMs,
|
||||
}
|
||||
if p.apiBase == "" {
|
||||
p.apiBase = "https://api.openai.com/v1"
|
||||
}
|
||||
if p.model == "" {
|
||||
p.model = "gpt-4o-mini-tts"
|
||||
}
|
||||
if p.voice == "" {
|
||||
p.voice = "alloy"
|
||||
}
|
||||
if p.timeoutMs <= 0 {
|
||||
p.timeoutMs = 30000
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *OpenAIProvider) Name() string { return "openai" }
|
||||
|
||||
// Synthesize calls the OpenAI audio/speech endpoint.
|
||||
// Matching TS: POST {apiBase}/audio/speech with {model, input, voice, response_format}.
|
||||
func (p *OpenAIProvider) Synthesize(ctx context.Context, text string, opts Options) (*SynthResult, error) {
|
||||
voice := opts.Voice
|
||||
if voice == "" {
|
||||
voice = p.voice
|
||||
}
|
||||
model := opts.Model
|
||||
if model == "" {
|
||||
model = p.model
|
||||
}
|
||||
format := opts.Format
|
||||
if format == "" {
|
||||
format = "mp3"
|
||||
}
|
||||
|
||||
body := map[string]any{
|
||||
"model": model,
|
||||
"input": text,
|
||||
"voice": voice,
|
||||
"response_format": format,
|
||||
}
|
||||
|
||||
bodyJSON, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal openai tts request: %w", err)
|
||||
}
|
||||
|
||||
url := p.apiBase + "/audio/speech"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyJSON))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create openai tts request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
||||
|
||||
client := &http.Client{Timeout: time.Duration(p.timeoutMs) * time.Millisecond}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("openai tts request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
errBody, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("openai tts error %d: %s", resp.StatusCode, string(errBody))
|
||||
}
|
||||
|
||||
audio, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read openai tts response: %w", err)
|
||||
}
|
||||
|
||||
ext := format
|
||||
mime := "audio/mpeg"
|
||||
switch format {
|
||||
case "opus":
|
||||
ext = "ogg"
|
||||
mime = "audio/ogg"
|
||||
case "mp3":
|
||||
mime = "audio/mpeg"
|
||||
}
|
||||
|
||||
return &SynthResult{
|
||||
Audio: audio,
|
||||
Extension: ext,
|
||||
MimeType: mime,
|
||||
}, nil
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
// Package tts provides text-to-speech functionality for GoClaw.
|
||||
// Matching TS src/tts/tts-core.ts + src/tts/tts.ts.
|
||||
//
|
||||
// Supported providers: OpenAI, ElevenLabs, Edge (Microsoft).
|
||||
// Auto modes: off, always, inbound, tagged.
|
||||
package tts
|
||||
|
||||
import "context"
|
||||
|
||||
// Provider synthesizes text into audio bytes.
|
||||
type Provider interface {
|
||||
Name() string
|
||||
Synthesize(ctx context.Context, text string, opts Options) (*SynthResult, error)
|
||||
}
|
||||
|
||||
// Options controls synthesis parameters.
|
||||
type Options struct {
|
||||
Voice string // provider-specific voice ID
|
||||
Model string // provider-specific model ID
|
||||
Format string // output format: "mp3", "opus" (default depends on channel)
|
||||
}
|
||||
|
||||
// SynthResult is the output of a TTS synthesis.
|
||||
type SynthResult struct {
|
||||
Audio []byte // raw audio bytes
|
||||
Extension string // file extension without dot: "mp3", "opus", "ogg"
|
||||
MimeType string // e.g. "audio/mpeg", "audio/ogg"
|
||||
}
|
||||
|
||||
// AutoMode controls when TTS is automatically applied.
|
||||
// Matching TS TtsAutoMode.
|
||||
type AutoMode string
|
||||
|
||||
const (
|
||||
AutoOff AutoMode = "off" // Disabled
|
||||
AutoAlways AutoMode = "always" // Apply to all eligible replies
|
||||
AutoInbound AutoMode = "inbound" // Only if user sent audio/voice
|
||||
AutoTagged AutoMode = "tagged" // Only if reply contains [[tts]] directive
|
||||
)
|
||||
|
||||
// Mode controls which reply types get TTS.
|
||||
// Matching TS TtsMode.
|
||||
type Mode string
|
||||
|
||||
const (
|
||||
ModeFinal Mode = "final" // Only final replies (default)
|
||||
ModeAll Mode = "all" // All replies including tool/block
|
||||
)
|
||||
Reference in new issue
Block a user