diff --git a/internal/tts/alias.go b/internal/tts/alias.go new file mode 100644 index 00000000..821d1cab --- /dev/null +++ b/internal/tts/alias.go @@ -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 +) diff --git a/internal/tts/alias_test.go b/internal/tts/alias_test.go new file mode 100644 index 00000000..6472e2df --- /dev/null +++ b/internal/tts/alias_test.go @@ -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 +} diff --git a/internal/tts/edge.go b/internal/tts/edge.go deleted file mode 100644 index 95fb22bf..00000000 --- a/internal/tts/edge.go +++ /dev/null @@ -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 -} diff --git a/internal/tts/elevenlabs.go b/internal/tts/elevenlabs.go deleted file mode 100644 index e9076d38..00000000 --- a/internal/tts/elevenlabs.go +++ /dev/null @@ -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 -} diff --git a/internal/tts/manager.go b/internal/tts/manager.go deleted file mode 100644 index a89594f8..00000000 --- a/internal/tts/manager.go +++ /dev/null @@ -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 -} diff --git a/internal/tts/minimax.go b/internal/tts/minimax.go deleted file mode 100644 index c3cdc87f..00000000 --- a/internal/tts/minimax.go +++ /dev/null @@ -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"` -} diff --git a/internal/tts/openai.go b/internal/tts/openai.go deleted file mode 100644 index d330e25e..00000000 --- a/internal/tts/openai.go +++ /dev/null @@ -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 -} diff --git a/internal/tts/types.go b/internal/tts/types.go deleted file mode 100644 index a7b75a02..00000000 --- a/internal/tts/types.go +++ /dev/null @@ -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 -)