// Package speak ist der eine Sprech-Kern von talk: Er setzt Config, Audio- // Sink und Provider-Kette zu einer Ansage zusammen. Der Daemon ruft ihn aus // dem Queue-Worker, die CLI nur im Notfall ohne Daemon (direkt, ohne // Warteschlange) — beide über genau diesen Weg, damit es keine zweite // Zusammensetzung der Kette gibt, die auseinanderlaufen könnte. package speak import ( "context" "io" "sync" "code.m3ta.dev/m3tam3re/talk/internal/audio" "code.m3ta.dev/m3tam3re/talk/internal/config" "code.m3ta.dev/m3tam3re/talk/internal/langdetect" "code.m3ta.dev/m3tam3re/talk/internal/provider" ) // NoProvider ist der Provider-Name, den ActiveProvider meldet, wenn keine // Stufe der Kette verfügbar ist (Status-Anzeige). const NoProvider = "keine" // Request ist eine Ansage: Text plus optionale Pins. Leere Pins heißen // „Default": Sprache wird erkannt, die Kette läuft in Config-Reihenfolge, // die Stimme kommt aus dem Voice-Mapping. type Request struct { Text string Lang string // de | en; leer = langdetect Provider string // Provider-Pin; leer = Config-Pin bzw. Default-Kette Voice string // Voice-ID-Pin; leer = Mapping/Defaults } // Engine hält Sink und Konfiguration und baut pro Ansage die Kette (Pin und // Stimme sind Eigenschaften der einzelnen Anfrage). Sie ist nebenläufig // nutzbar; der Daemon spricht ohnehin seriell. type Engine struct { cfg config.Config sink audio.Sink getenv func(string) string log io.Writer base *provider.Chain // ungepinnte Kette: Verfügbarkeits-Auflösung für Status mu sync.Mutex last string // zuletzt verwendeter Provider (Status im Leerlauf) } // New baut den Sprech-Kern: Sink nach cfg.Audio.Sink, Basiskette nach // Config. getenv liefert Keys und Endpunkt-Overrides (nil = leer), log // empfängt Ketten- und Sink-Protokoll (nil = still). func New(cfg config.Config, getenv func(string) string, log io.Writer) (*Engine, error) { if getenv == nil { getenv = func(string) string { return "" } } sink, err := audio.NewSink(cfg.Audio.Sink, log) if err != nil { return nil, err } base, err := provider.NewChain(chainConfig(cfg, "", "", getenv, nil, log)) if err != nil { return nil, err } return &Engine{cfg: cfg, sink: sink, getenv: getenv, log: log, base: base}, nil } // Speak spricht eine Anfrage: Sprache erkennen (falls nicht gepinnt), Kette // bauen, synthetisieren mit Fallback, über den Sink abspielen. Blockiert // bis zum Ende der Wiedergabe oder bis ctx abgebrochen wird. func (e *Engine) Speak(ctx context.Context, req Request) error { lang := req.Lang if lang == "" { lang = langdetect.Detect(req.Text) } e.setLast(e.ActiveProvider(req.Provider)) chain, err := provider.NewChain(chainConfig(e.cfg, req.Provider, req.Voice, e.getenv, e.sink, e.log)) if err != nil { return err } return chain.Speak(ctx, req.Text, lang) } // ActiveProvider löst den „aktiven Provider" für eine Anfrage auf: der Pin, // sonst die erste verfügbare Stufe der Basiskette (Available ist billig — // Key-/Binary-Prüfung ohne Netz), sonst NoProvider. func (e *Engine) ActiveProvider(pin string) string { if pin != "" { return pin } for _, p := range e.base.Tiers() { if p.Available(context.Background()) { return p.Name() } } return NoProvider } // LastProvider liefert den Provider der zuletzt gestarteten Ansage; leer, // wenn noch nichts gesprochen wurde. func (e *Engine) LastProvider() string { e.mu.Lock() defer e.mu.Unlock() return e.last } // SinkName liefert den Namen des Audio-Sinks (oto | null). func (e *Engine) SinkName() string { return e.sink.Name() } func (e *Engine) setLast(name string) { e.mu.Lock() e.last = name e.mu.Unlock() } // chainConfig überführt Config + Anfrage-Pins in die ChainConfig: Anfrage- // Pin schlägt den Config-Pin `provider = "…"`; Endpunkte und das OpenAI- // Modell kommen aus [providers.]. func chainConfig(cfg config.Config, pin, voice string, getenv func(string) string, player provider.Player, log io.Writer) provider.ChainConfig { if pin == "" { pin = cfg.Provider } endpoints := make(map[string]string, len(cfg.Providers)) for name, settings := range cfg.Providers { if settings.Endpoint != "" { endpoints[name] = settings.Endpoint } } return provider.ChainConfig{ Pin: pin, Voice: voice, Voices: cfg.Voice, Endpoints: endpoints, Model: cfg.Providers["openai"].Model, Getenv: getenv, Player: player, Log: log, } }