2026-10-18 16:34:43 +02:00
|
|
|
package provider
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"fmt"
|
|
|
|
|
"net/http"
|
|
|
|
|
"strings"
|
|
|
|
|
)
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// ElevenLabs constants (public contract: the env names are part of the
|
|
|
|
|
// fleet documentation, the endpoint default part of the configurability).
|
2026-10-18 16:34:43 +02:00
|
|
|
const (
|
2026-10-24 11:15:35 +02:00
|
|
|
// DefaultEndpointElevenLabs is the production API endpoint.
|
2026-10-18 16:34:43 +02:00
|
|
|
DefaultEndpointElevenLabs = "https://api.elevenlabs.io"
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// EnvElevenLabsKey provides the API key (spec: keys come exclusively
|
|
|
|
|
// from the environment, never from config.toml).
|
2026-10-18 16:34:43 +02:00
|
|
|
EnvElevenLabsKey = "TALK_ELEVENLABS_KEY"
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// EnvElevenLabsEndpoint overrides the endpoint (tests/corporate
|
|
|
|
|
// servers; beats config.toml, see the voices.go/chain.go docs).
|
2026-10-18 16:34:43 +02:00
|
|
|
EnvElevenLabsEndpoint = "TALK_ELEVENLABS_ENDPOINT"
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
elevenLabsModel = "eleven_multilingual_v2" // multilingual v2
|
2026-10-18 16:34:43 +02:00
|
|
|
)
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// ElevenLabs is the premium tier of the chain. Synthesize performs POST
|
|
|
|
|
// <endpoint>/v1/text-to-speech/<voiceID> with header `xi-api-key` and body
|
2026-10-18 16:34:43 +02:00
|
|
|
// {text, model_id: "eleven_multilingual_v2", voice_settings:
|
2026-10-24 11:15:35 +02:00
|
|
|
// {stability: 0.5, similarity_boost: 0.75}} and returns the response bytes
|
|
|
|
|
// unchanged. Return format: MP3 (documented contract of the audio package).
|
2026-10-18 16:34:43 +02:00
|
|
|
type ElevenLabs struct {
|
2026-10-24 11:15:35 +02:00
|
|
|
// Endpoint is the base URL (without path); empty is replaced by the
|
|
|
|
|
// default at construction.
|
2026-10-18 16:34:43 +02:00
|
|
|
Endpoint string
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// APIKey is the ElevenLabs key (from TALK_ELEVENLABS_KEY).
|
2026-10-18 16:34:43 +02:00
|
|
|
APIKey string
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Voices is the config mapping voice.elevenlabs.<lang> = id; empty =
|
|
|
|
|
// neutral repo defaults (see voices.go).
|
2026-10-18 16:34:43 +02:00
|
|
|
Voices map[string]string
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Client is the HTTP client; nil = shared client with timeout
|
2026-10-18 16:34:43 +02:00
|
|
|
// (cloudTimeout).
|
|
|
|
|
Client *http.Client
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
pinned string // --voice pin (VoicePinner)
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// NewElevenLabs builds the provider from the chain configuration: endpoint
|
|
|
|
|
// from TALK_ELEVENLABS_ENDPOINT > [providers.elevenlabs].endpoint > default;
|
|
|
|
|
// key exclusively from TALK_ELEVENLABS_KEY.
|
2026-10-18 16:34:43 +02:00
|
|
|
func NewElevenLabs(cfg ChainConfig) *ElevenLabs {
|
|
|
|
|
return &ElevenLabs{
|
|
|
|
|
Endpoint: endpointFrom(cfg, "elevenlabs", EnvElevenLabsEndpoint, DefaultEndpointElevenLabs),
|
|
|
|
|
APIKey: cfg.Getenv(EnvElevenLabsKey),
|
|
|
|
|
Voices: cfg.Voices["elevenlabs"],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Name implements Provider.
|
2026-10-18 16:34:43 +02:00
|
|
|
func (*ElevenLabs) Name() string { return "elevenlabs" }
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// PinVoice implements VoicePinner (CLI flag --voice).
|
2026-10-18 16:34:43 +02:00
|
|
|
func (p *ElevenLabs) PinVoice(id string) { p.pinned = id }
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Available implements Provider: key and endpoint must be set (deliberately
|
|
|
|
|
// without a network call; Available must stay cheap. Whether the key is
|
|
|
|
|
// valid is determined by Synthesize, and the chain falls back).
|
2026-10-18 16:34:43 +02:00
|
|
|
func (p *ElevenLabs) Available(ctx context.Context) bool {
|
|
|
|
|
return p.APIKey != "" && p.Endpoint != ""
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// elevenLabsRequest is the request body (literal values from the API
|
|
|
|
|
// contract).
|
2026-10-18 16:34:43 +02:00
|
|
|
type elevenLabsRequest struct {
|
|
|
|
|
Text string `json:"text"`
|
|
|
|
|
ModelID string `json:"model_id"`
|
|
|
|
|
VoiceSettings elevenLabsVoiceSettings `json:"voice_settings"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type elevenLabsVoiceSettings struct {
|
|
|
|
|
Stability float64 `json:"stability"`
|
|
|
|
|
SimilarityBoost float64 `json:"similarity_boost"`
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Synthesize implements Provider. Return format: MP3.
|
2026-10-18 16:34:43 +02:00
|
|
|
func (p *ElevenLabs) Synthesize(ctx context.Context, text, lang string) ([]byte, error) {
|
|
|
|
|
if text == "" {
|
2026-10-24 11:15:35 +02:00
|
|
|
return nil, fmt.Errorf("elevenlabs: empty text")
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
voice := resolveVoice("elevenlabs", lang, p.pinned, p.Voices)
|
|
|
|
|
if voice == "" {
|
2026-10-24 11:15:35 +02:00
|
|
|
return nil, fmt.Errorf("elevenlabs: no voice configured for language %q", lang)
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
body, err := json.Marshal(elevenLabsRequest{
|
|
|
|
|
Text: text,
|
|
|
|
|
ModelID: elevenLabsModel,
|
|
|
|
|
VoiceSettings: elevenLabsVoiceSettings{Stability: 0.5, SimilarityBoost: 0.75},
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
return nil, fmt.Errorf("elevenlabs: building request body: %w", err)
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
url := strings.TrimRight(p.Endpoint, "/") + "/v1/text-to-speech/" + voice
|
|
|
|
|
return postForBytes(ctx, p.Client, "elevenlabs", url,
|
|
|
|
|
map[string]string{
|
|
|
|
|
"xi-api-key": p.APIKey,
|
|
|
|
|
"Accept": "audio/mpeg",
|
|
|
|
|
}, body)
|
|
|
|
|
}
|