diff --git a/internal/config/config.go b/internal/config/config.go index d71346a..e6627cf 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -17,10 +17,28 @@ const ( ) // Config ist der effektiv geladene Konfigurations-Stand von talk. Task 02 -// führt nur audio.sink; spätere Tasks ergänzen Felder (Provider-Endpunkte, -// Voice-Mapping, server, queue …), ohne die Auflösungsreihenfolge zu ändern. +// führt audio.sink; Task 03 ergänzt den Provider-Pin, Provider-Endpunkte +// und das Voice-Mapping; spätere Tasks ergänzen weitere Felder (server, +// queue …), ohne die Auflösungsreihenfolge zu ändern. type Config struct { Audio Audio `toml:"audio"` + + // Provider pinnt die Kette auf genau einen Provider (z. B. + // `provider = "elevenlabs"`); leer = komplette Default-Kette + // (elevenlabs → openai → edge → osbuiltin). Das CLI-Flag --provider + // schlägt diesen Wert. Gültige Namen prüft die Provider-Schicht. + Provider string `toml:"provider"` + + // Providers enthält per Provider-Einstellungen (Endpunkt, Modell) — + // TOML `[providers.elevenlabs] endpoint = "…"`. Key `providers` + // (Mehrzahl), damit der Pin ( singular `provider`) und die + // Einstellungs-Tabelle in TOML nebeneinander existieren können. + Providers map[string]ProviderSettings `toml:"providers"` + + // Voice ist das Voice-Mapping `voice.. = id` — z. B. + // `[voice.elevenlabs] de = "21m00Tcm4TlvDq8ikWAM"`. Schlägt die + // eingebauten neutralen Defaults; das CLI-Flag --voice schlägt alles. + Voice map[string]map[string]string `toml:"voice"` } // Audio steuert die Audio-Ausgabe. @@ -30,6 +48,17 @@ type Audio struct { Sink string `toml:"sink"` } +// ProviderSettings sind die per Provider konfigurierbaren Endpunkte und +// Modelle (TOML `[providers.]`). Leer = eingebaute Defaults; die +// Env-Overrides TALK_ELEVENLABS_ENDPOINT / TALK_OPENAI_ENDPOINT / +// TALK_EDGE_ENDPOINT (ausgewertet in der Provider-Schicht) schlagen die +// Datei — Keys laufen ausschließlich über TALK_ELEVENLABS_KEY bzw. +// TALK_OPENAI_KEY und werden nie in die Datei geschrieben. +type ProviderSettings struct { + Endpoint string `toml:"endpoint"` + Model string `toml:"model"` // nur openai (Default "tts-1") +} + // Defaults liefert die eingebaute Default-Konfiguration (unterste Schicht // der Auflösung). func Defaults() Config { @@ -84,6 +113,9 @@ func applyEnv(cfg Config, getenv func(string) string) (Config, error) { if v := getenv("TALK_AUDIO_SINK"); v != "" { cfg.Audio.Sink = v } + if v := getenv("TALK_PROVIDER"); v != "" { + cfg.Provider = v + } switch cfg.Audio.Sink { case SinkOto, SinkNull: return cfg, nil diff --git a/internal/config/config_test.go b/internal/config/config_test.go index a71cba8..17faa03 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -204,3 +204,87 @@ func TestLoadMerge(t *testing.T) { } }) } + +// TestLoadProviderKette deckt die Task-03-Felder ab: Provider-Pin per +// Datei/Env, Provider-Endpunkte/-Modelle und das Voice-Mapping. +func TestLoadProviderKette(t *testing.T) { + writeTOML := func(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "config.toml") + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + return path + } + + toml := `provider = "openai" + +[providers.elevenlabs] +endpoint = "https://eleven.example.com" + +[providers.openai] +endpoint = "https://openai.example.com" +model = "gpt-4o-mini-tts" + +[providers.edge] +endpoint = "wss://edge.example.com/synth" + +[voice.elevenlabs] +de = "MeineStimme" +en = "MyVoice" + +[voice.edge] +de = "de-DE-KatjaNeural" +` + + t.Run("TOML liefert Pin, Endpunkte und Voice-Mapping", func(t *testing.T) { + cfg, err := Load(Options{File: writeTOML(t, toml), Getenv: env(nil)}) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Provider != "openai" { + t.Errorf("Provider = %q, want %q", cfg.Provider, "openai") + } + if got := cfg.Providers["elevenlabs"].Endpoint; got != "https://eleven.example.com" { + t.Errorf("Providers[elevenlabs].Endpoint = %q", got) + } + if got := cfg.Providers["openai"].Model; got != "gpt-4o-mini-tts" { + t.Errorf("Providers[openai].Model = %q", got) + } + if got := cfg.Providers["edge"].Endpoint; got != "wss://edge.example.com/synth" { + t.Errorf("Providers[edge].Endpoint = %q", got) + } + if got := cfg.Voice["elevenlabs"]["de"]; got != "MeineStimme" { + t.Errorf("Voice[elevenlabs][de] = %q", got) + } + if got := cfg.Voice["elevenlabs"]["en"]; got != "MyVoice" { + t.Errorf("Voice[elevenlabs][en] = %q", got) + } + }) + + t.Run("TALK_PROVIDER schlägt Datei-Pin", func(t *testing.T) { + cfg, err := Load(Options{ + File: writeTOML(t, toml), + Getenv: env(map[string]string{"TALK_PROVIDER": "edge"}), + }) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Provider != "edge" { + t.Errorf("Provider = %q, want Env-Override %q", cfg.Provider, "edge") + } + }) + + t.Run("ohne alles: keine Pins, keine Endpunkte", func(t *testing.T) { + cfg, err := Load(Options{ + File: filepath.Join(t.TempDir(), "gibt-es-nicht.toml"), + Getenv: env(nil), + }) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Provider != "" || cfg.Providers != nil || cfg.Voice != nil { + t.Errorf("Provider-Konfiguration = %+v, will leer", cfg) + } + }) +}