- elevenlabs: POST /v1/text-to-speech/{voice}, Header xi-api-key, Body mit
model_id eleven_multilingual_v2 und voice_settings 0.5/0.75, MP3-Bytes.
- openai: POST /v1/audio/speech, Bearer-Key, Body {model, voice, input,
response_format:mp3}; Modell konfigurierbar (Default tts-1).
- edge: Microsofts Read-Aloud-WSS-Protokoll nach edge-tts-Referenz,
inklusive aktuellem DRM Sec-MS-GEC v2 (CS64 + verschleierte SHA-256-
Variante, wörtlich portiert und gegen Referenzvektoren getestet) und
MUID-Cookie; bei 403 Uhrenkorrektur aus dem Date-Header + ein Retry.
Klare Protokoll-geaendert-Fehler statt stiller Fehlfunktion.
- chain: geordnete Kette (elevenlabs -> openai -> edge -> osbuiltin), Pin
per --provider/config, Available ohne Netz-Calls, Fallback mit
Protokoll je Stufe (nie still). Chain implementiert Provider UND
DirectSpeaker: Speak orchestriert Bytes -> Player bzw. Direktansage,
verwirft am null-Player tonlos — CLI und Daemon (Task 04) nutzen
dieselbe Kette unverändert.
- voices: neutrale Repo-Defaults je Provider+Sprache (Rachel/alloy/
Katja/Aria); Rangfolge --voice > config-Mapping > Default. KXxZ… bleibt
bewusst Privat-Default der eigenen config.toml (Spec: nicht
Repo-Default).
Tests: Cloud-Provider gegen httptest-Fakes (200/500/401, Request-Vertrag
wörtlich), Edge über Interface + Fehlerpfad-Mocks + Loopback-WSS-Fake
(kein echtes Netz) + als testing.Short überspringbare Integration-
Markierung (TALK_TEST_REAL_EDGE=1, am Dev-Host einmalig ausgeführt:
17136 Bytes echtes Audio). Kette: erster 500 -> zweiter getroffen -> Bytes;
Pin, alle-Stufen-tot, Direktansage mit/ohne null-Player.
107 lines
3.3 KiB
Go
107 lines
3.3 KiB
Go
package provider
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
)
|
|
|
|
// OpenAI-TTS-Konstanten (öffentlicher Vertrag wie bei ElevenLabs).
|
|
const (
|
|
// DefaultEndpointOpenAI ist der produktive API-Endpunkt.
|
|
DefaultEndpointOpenAI = "https://api.openai.com"
|
|
|
|
// DefaultModelOpenAI: Entscheidungsnotiz aus dem Task-Brief — der
|
|
// Modell-String ist konfigurierbar ([providers.openai] model), Default
|
|
// "tts-1"; "gpt-4o-mini-tts" ist ein gültiger Alternativwert.
|
|
DefaultModelOpenAI = "tts-1"
|
|
|
|
EnvOpenAIKey = "TALK_OPENAI_KEY"
|
|
EnvOpenAIEndpoint = "TALK_OPENAI_ENDPOINT"
|
|
)
|
|
|
|
// OpenAI ist die zweite Stufe der Kette (für Nutzer mit vorhandenem
|
|
// OpenAI-Key). Synthesize führt POST <endpoint>/v1/audio/speech aus —
|
|
// Bearer-Key, Body {model, voice, input, response_format: "mp3"} — und
|
|
// liefert die Antwort-Bytes unverändert zurück. Rückgabeformat: MP3.
|
|
type OpenAI struct {
|
|
// Endpoint ist die Basis-URL (ohne Pfad); leer = Default beim Bau.
|
|
Endpoint string
|
|
|
|
// APIKey ist der OpenAI-Key (aus TALK_OPENAI_KEY).
|
|
APIKey string
|
|
|
|
// Model ist der TTS-Modell-String; leer = DefaultModelOpenAI.
|
|
Model string
|
|
|
|
// Voices ist das config-Mapping voice.openai.<lang> = id; leer =
|
|
// neutrale Repo-Defaults (alloy, siehe voices.go).
|
|
Voices map[string]string
|
|
|
|
// Client ist der HTTP-Client; nil = geteilter Client mit Timeout.
|
|
Client *http.Client
|
|
|
|
pinned string // --voice-Pin (VoicePinner)
|
|
}
|
|
|
|
// NewOpenAI baut den Provider aus der Ketten-Konfiguration: Endpunkt aus
|
|
// TALK_OPENAI_ENDPOINT > [providers.openai].endpoint > Default; Modell aus
|
|
// [providers.openai].model; Key ausschließlich aus TALK_OPENAI_KEY.
|
|
func NewOpenAI(cfg ChainConfig) *OpenAI {
|
|
return &OpenAI{
|
|
Endpoint: endpointFrom(cfg, "openai", EnvOpenAIEndpoint, DefaultEndpointOpenAI),
|
|
APIKey: cfg.Getenv(EnvOpenAIKey),
|
|
Model: cfg.Model,
|
|
Voices: cfg.Voices["openai"],
|
|
}
|
|
}
|
|
|
|
// Name implementiert Provider.
|
|
func (*OpenAI) Name() string { return "openai" }
|
|
|
|
// PinVoice implementiert VoicePinner (CLI-Flag --voice).
|
|
func (p *OpenAI) PinVoice(id string) { p.pinned = id }
|
|
|
|
// Available implementiert Provider: Key und Endpunkt müssen gesetzt sein
|
|
// (ohne Netz-Call, siehe ElevenLabs.Available).
|
|
func (p *OpenAI) Available(ctx context.Context) bool {
|
|
return p.APIKey != "" && p.Endpoint != ""
|
|
}
|
|
|
|
// openAIRequest ist der Anfrage-Body.
|
|
type openAIRequest struct {
|
|
Model string `json:"model"`
|
|
Voice string `json:"voice"`
|
|
Input string `json:"input"`
|
|
ResponseFormat string `json:"response_format"`
|
|
}
|
|
|
|
// Synthesize implementiert Provider. Rückgabeformat: MP3.
|
|
func (p *OpenAI) Synthesize(ctx context.Context, text, lang string) ([]byte, error) {
|
|
if text == "" {
|
|
return nil, fmt.Errorf("openai: leerer Text")
|
|
}
|
|
voice := resolveVoice("openai", lang, p.pinned, p.Voices)
|
|
if voice == "" {
|
|
return nil, fmt.Errorf("openai: keine Stimme für Sprache %q konfiguriert", lang)
|
|
}
|
|
model := p.Model
|
|
if model == "" {
|
|
model = DefaultModelOpenAI
|
|
}
|
|
body, err := json.Marshal(openAIRequest{
|
|
Model: model,
|
|
Voice: voice,
|
|
Input: text,
|
|
ResponseFormat: "mp3",
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("openai: Anfrage-Body bauen: %w", err)
|
|
}
|
|
url := strings.TrimRight(p.Endpoint, "/") + "/v1/audio/speech"
|
|
return postForBytes(ctx, p.Client, "openai", url,
|
|
map[string]string{"Authorization": "Bearer " + p.APIKey}, body)
|
|
}
|