2026-10-18 15:38:56 +02:00
|
|
|
|
package config
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"errors"
|
|
|
|
|
|
"fmt"
|
|
|
|
|
|
"os"
|
|
|
|
|
|
"path/filepath"
|
2026-10-18 17:10:26 +02:00
|
|
|
|
"strconv"
|
2026-10-18 15:38:56 +02:00
|
|
|
|
|
|
|
|
|
|
"github.com/BurntSushi/toml"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// Erlaubte Werte für audio.sink (Teil des öffentlichen Vertrags; Spec:
|
|
|
|
|
|
// „Audio-Sink wählbar inkl. null").
|
|
|
|
|
|
const (
|
|
|
|
|
|
SinkOto = "oto"
|
|
|
|
|
|
SinkNull = "null"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-10-18 17:10:26 +02:00
|
|
|
|
// DefaultServerPort ist der Default-Port des Daemon-HTTP-Servers auf
|
|
|
|
|
|
// 127.0.0.1 (Task-04-Brief: „Port Default 7800").
|
|
|
|
|
|
const DefaultServerPort = 7800
|
|
|
|
|
|
|
2026-10-18 15:38:56 +02:00
|
|
|
|
// Config ist der effektiv geladene Konfigurations-Stand von talk. Task 02
|
2026-10-18 16:34:24 +02:00
|
|
|
|
// führt audio.sink; Task 03 ergänzt den Provider-Pin, Provider-Endpunkte
|
2026-10-18 17:10:26 +02:00
|
|
|
|
// und das Voice-Mapping; Task 04 den Daemon-Port (server.port, TALK_PORT);
|
|
|
|
|
|
// spätere Tasks ergänzen weitere Felder (queue …), ohne die Auflösungs-
|
|
|
|
|
|
// reihenfolge zu ändern.
|
2026-10-18 15:38:56 +02:00
|
|
|
|
type Config struct {
|
|
|
|
|
|
Audio Audio `toml:"audio"`
|
2026-10-18 16:34:24 +02:00
|
|
|
|
|
2026-10-18 17:10:26 +02:00
|
|
|
|
// Server steuert den Daemon-HTTP-Server (talkd) auf 127.0.0.1.
|
|
|
|
|
|
Server Server `toml:"server"`
|
|
|
|
|
|
|
2026-10-18 16:34:24 +02:00
|
|
|
|
// 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.<provider>.<lang> = 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"`
|
2026-10-18 15:38:56 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Audio steuert die Audio-Ausgabe.
|
|
|
|
|
|
type Audio struct {
|
|
|
|
|
|
// Sink wählt die Wiedergabe: "oto" (embedded Playback, Default) oder
|
|
|
|
|
|
// "null" (verwirft — CI, Server, Tests).
|
|
|
|
|
|
Sink string `toml:"sink"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-18 17:10:26 +02:00
|
|
|
|
// Server sind die Netz-Einstellungen des Daemons (nur Loopback; Binding
|
|
|
|
|
|
// auf 127.0.0.1 macht cmd/talkd, die Config liefert nur den Port).
|
|
|
|
|
|
type Server struct {
|
|
|
|
|
|
// Port des Daemon-HTTP-Servers auf 127.0.0.1 — config
|
|
|
|
|
|
// `[server] port = 7800`, Env-Override TALK_PORT, Default 7800.
|
|
|
|
|
|
Port int `toml:"port"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-18 16:34:24 +02:00
|
|
|
|
// ProviderSettings sind die per Provider konfigurierbaren Endpunkte und
|
|
|
|
|
|
// Modelle (TOML `[providers.<name>]`). 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")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-18 15:38:56 +02:00
|
|
|
|
// Defaults liefert die eingebaute Default-Konfiguration (unterste Schicht
|
|
|
|
|
|
// der Auflösung).
|
|
|
|
|
|
func Defaults() Config {
|
2026-10-18 17:10:26 +02:00
|
|
|
|
return Config{
|
|
|
|
|
|
Audio: Audio{Sink: SinkOto},
|
|
|
|
|
|
Server: Server{Port: DefaultServerPort},
|
|
|
|
|
|
}
|
2026-10-18 15:38:56 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Options steuert Load. Alle Felder sind injizierbar, damit die Unit-Tests
|
|
|
|
|
|
// ohne echte HOME-/Env-Umgebung und ohne echte Dateien auskommen.
|
|
|
|
|
|
type Options struct {
|
|
|
|
|
|
// File überschreibt den platform-nativen Pfad der config.toml (Tests
|
|
|
|
|
|
// injizieren hier eine Temp-Datei). Leer: Dir() + "config.toml".
|
|
|
|
|
|
File string
|
|
|
|
|
|
|
|
|
|
|
|
// Getenv liefert Umgebungsvariablen (Default: os.Getenv). Über TALK_*
|
|
|
|
|
|
// laufen die Env-Overrides sowie die Pfadauflösung (HOME,
|
|
|
|
|
|
// XDG_CONFIG_HOME, APPDATA).
|
|
|
|
|
|
Getenv func(string) string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Load löst die Konfiguration schichtweise auf: Defaults → config.toml
|
|
|
|
|
|
// (falls vorhanden) → Env-Overrides (TALK_AUDIO_SINK schlägt die Datei).
|
|
|
|
|
|
// Env- und Datei-Validierungsfehler werden als klarer Fehler gemeldet, nie
|
|
|
|
|
|
// still ignoriert. Fehlt das Config-Verzeichnis (z. B. keine HOME-Variable),
|
|
|
|
|
|
// läuft talk mit Defaults + Env weiter — die Datei ist optional, kaputte
|
|
|
|
|
|
// Umgebungen dürfen talk nicht unbrauchbar machen.
|
|
|
|
|
|
func Load(opts Options) (Config, error) {
|
|
|
|
|
|
if opts.Getenv == nil {
|
|
|
|
|
|
opts.Getenv = os.Getenv
|
|
|
|
|
|
}
|
|
|
|
|
|
cfg := Defaults()
|
|
|
|
|
|
|
|
|
|
|
|
path := opts.File
|
|
|
|
|
|
if path == "" {
|
|
|
|
|
|
dir, err := Dir(opts.Getenv)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
// Kein auffindbares Config-Verzeichnis: optionale Datei entfällt.
|
|
|
|
|
|
return applyEnv(cfg, opts.Getenv)
|
|
|
|
|
|
}
|
|
|
|
|
|
path = filepath.Join(dir, fileName)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if _, err := toml.DecodeFile(path, &cfg); err != nil {
|
|
|
|
|
|
if !errors.Is(err, os.ErrNotExist) {
|
|
|
|
|
|
return cfg, fmt.Errorf("config %s: %w", path, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return applyEnv(cfg, opts.Getenv)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// applyEnv wendet die Env-Overrides an und validiert das Ergebnis.
|
|
|
|
|
|
func applyEnv(cfg Config, getenv func(string) string) (Config, error) {
|
|
|
|
|
|
if v := getenv("TALK_AUDIO_SINK"); v != "" {
|
|
|
|
|
|
cfg.Audio.Sink = v
|
|
|
|
|
|
}
|
2026-10-18 16:34:24 +02:00
|
|
|
|
if v := getenv("TALK_PROVIDER"); v != "" {
|
|
|
|
|
|
cfg.Provider = v
|
|
|
|
|
|
}
|
2026-10-18 17:10:26 +02:00
|
|
|
|
if v := getenv("TALK_PORT"); v != "" {
|
|
|
|
|
|
port, err := strconv.Atoi(v)
|
|
|
|
|
|
if err != nil || port < 1 || port > 65535 {
|
|
|
|
|
|
return cfg, fmt.Errorf("TALK_PORT: ungültiger Wert %q (1–65535)", v)
|
|
|
|
|
|
}
|
|
|
|
|
|
cfg.Server.Port = port
|
|
|
|
|
|
}
|
2026-10-18 15:38:56 +02:00
|
|
|
|
switch cfg.Audio.Sink {
|
|
|
|
|
|
case SinkOto, SinkNull:
|
|
|
|
|
|
return cfg, nil
|
|
|
|
|
|
default:
|
|
|
|
|
|
return cfg, fmt.Errorf("audio.sink: ungültiger Wert %q (erlaubt: %q, %q)",
|
|
|
|
|
|
cfg.Audio.Sink, SinkOto, SinkNull)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|