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:
viettranx committed 2026-04-15 11:24:56 +07:00
1 parent f4cc595e50
commit 214bd83bde
8 files changed
+156 -778

No files matched your search

+74
View File
@@ -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
)
+82
View File
@@ -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
}
-84
View File
@@ -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
}
-126
View File
@@ -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
}
-220
View File
@@ -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
}
-174
View File
@@ -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"`
}
-126
View File
@@ -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
}
-48
View File
@@ -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
)