Files
talk/internal/provider/elevenlabs.go
T
m3ta-chironandClaude Fable 5.1 85fa64cfe6 refactor: switch the whole project to English
talk is going to be published. Code comments, CLI output, error messages,
daemon log lines, MCP descriptions and results, test names and assertions,
README, spec, skill, build scripts and flake are now English. Only the
spoken announcement texts stay bilingual: the wrap result and the test
sentence are German or English depending on the detected language.

Language fallback is now configurable: `lang = "en" | "de"` (env
TALK_LANG, default en) is used when detection cannot tell (numbers, single
words, ties) and for talk's own announcements. langdetect.Detect takes the
fallback as an argument instead of hard-coding German. `talk setup` asks for
it; `talk doctor` and `talk status` show it.

Vocabulary pinned across packages so the process-boundary tests observe
stable strings: chain log lines ("chain: tier X skipped (not available)",
"falling back to the next tier", "spoken via X"), queue lines ("discarded:
quiet hours ... active", "replaced by toast", "max_age"), CLI notes
("speaking directly", "daemon started at", "no daemon", "nothing to cancel"),
status block with aligned English labels, MCP messages, origin guard 403.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-24 11:15:35 +02:00

113 lines
3.8 KiB
Go

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