Usability-Schicht für Nutzer, die kein Terminal-Handbuch lesen wollen: - `talk doctor`: eine Zeile je Prüfpunkt mit klarem Satz, was Sache ist und was zu tun wäre — Config (kaputt → ✘, die übrigen Prüfungen laufen mit Defaults weiter), Daemon (läuft / startet beim nächsten Sprechbefehl / fremder Dienst auf dem Port → ✘ mit Port-Hinweis), Audio (oto-Kontext wirklich initialisieren; null → ⚠), jede Provider-Stufe (Key-Env-Name, Endpunkt, Default-Stimme; OS-Stimme nennt den gefundenen Mechanismus), Mute (an → ⚠ mit unmute-Hinweis), Gesetze inkl. gerade aktiver Ruhezeit. --probe macht echte Synthese-Proben, --json liefert die Liste. Exit 0 ohne ✘. Windows-Konsolen bekommen [OK]/[WARN]/[FAIL]. - `talk test`: Testsatz direkt über den Sprech-Kern (nicht über den Daemon), nennt die Stufe, die wirklich gesprochen hat, Synthese- und Wiedergabezeit; Mute/Ruhezeit gelten bewusst nicht, Mute wird genannt. - `talk voices`: kuratierter Katalog mit Namen (ElevenLabs-Premade, OpenAI, Edge-Neural de/en-Locales) als Tabelle, „*“ = effektive Default-Stimme (Config vor Repo-Default), Config-IDs außerhalb des Katalogs als eigene Zeile, Filter --provider/--lang, --json. - `talk setup`: wenige Fragen statt Bubble Tea — Keys nur als Hinweis (ohne Key läuft die Edge-Stimme), Stimme je Sprache aus dem Katalog mit Probehören, Ruhezeit; schreibt die config.toml zusammengeführt mit bestehenden Werten (.bak), spricht einen Testsatz. q bricht ab, bevor etwas geschrieben wird; ohne Terminal keine Fragen, nur die wirksame Konfiguration. Unterbau: provider.Catalog/VoiceName/DefaultVoice, Chain.SpeakOutcome (Stufe, Bytes, Zeiten) durch speak.Engine bis in Daemon-Log und test, audio.Check (oto-Initialisierung ohne Ton), config.Path, OSBuiltin.Describe. README: Abschnitt „Erste Schritte“. Tests: voices-Zeilen (Default-Markierung, Config-Override, Filter, JSON), doctor (ohne Daemon, Mute/Key via JSON, fremder Dienst → Exit 1, kaputte Config → ✘ aber vollständiger Bericht), test (fake espeak), setup (stdin-Antworten → config.toml-Inhalt, Merge mit .bak, Abbruch schreibt nichts, Pipe stellt keine Fragen), Prozessgrenze: doctor/--probe/voices/ test gegen echten Daemon. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
145 lines
4.7 KiB
Go
145 lines
4.7 KiB
Go
// 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. Das Outcome
|
|
// nennt die Stufe, die wirklich gesprochen hat, und die Zeiten.
|
|
func (e *Engine) Speak(ctx context.Context, req Request) (provider.Outcome, error) {
|
|
lang := req.Lang
|
|
if lang == "" {
|
|
lang = langdetect.Detect(req.Text)
|
|
}
|
|
chain, err := provider.NewChain(chainConfig(e.cfg, req.Provider, req.Voice, e.getenv, e.sink, e.log))
|
|
if err != nil {
|
|
return provider.Outcome{}, err
|
|
}
|
|
out, err := chain.SpeakOutcome(ctx, req.Text, lang)
|
|
if err == nil {
|
|
e.setLast(out.Provider)
|
|
}
|
|
return out, err
|
|
}
|
|
|
|
// Chain liefert die ungepinnte Basiskette (Diagnose: Stufen und ihre
|
|
// Verfügbarkeit).
|
|
func (e *Engine) Chain() *provider.Chain { return e.base }
|
|
|
|
// 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.<name>].
|
|
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,
|
|
}
|
|
}
|