diff --git a/README.md b/README.md index a01a391..8434689 100644 --- a/README.md +++ b/README.md @@ -21,19 +21,58 @@ Runtime-Abhängigkeiten. | `talk status` | Daemon-Status (Queue, Mute, Provider, Version) | | `talk cancel` | laufende und geplante Ansagen abbrechen | | `talk daemon run\|start\|stop` | Daemon im Vordergrund betreiben / im Hintergrund starten / stoppen (normalerweise nicht nötig, siehe unten) | -| `talk voices` | verfügbare Stimmen anzeigen | -| `talk test` | Test-Ansage ausgeben | -| `talk doctor` | Selbstdiagnose des Setups | -| `talk setup` | geführte Ersteinrichtung (Wizard) | +| `talk voices` | bekannte Stimmen je Provider/Sprache, Default markiert (`--provider`, `--lang`, `--json`) | +| `talk test` | Testsatz sprechen; zeigt Provider und Zeiten (`--lang`, `--provider`, `--voice`) | +| `talk doctor` | Selbstdiagnose: Config, Daemon, Audio, Provider, Mute, Gesetze (`--probe`, `--json`) | +| `talk setup` | Ersteinrichtung in wenigen Fragen: Stimmen probehören, Ruhezeit, config.toml | Say-Flags (gelten auch für `talk "Text"`): `--wait`, `--now`, `--priority`, `--voice`, `--lang`, `--provider`. -**Hinweis:** In diesem Stand sind implementiert: `say` (und `talk "Text"`) -mit der kompletten Provider-Kette und Daemon-Anbindung (non-blocking, -Autostart, siehe unten), `mute`, `unmute`, `status`, `cancel`, `daemon` -sowie `wrap`. Die übrigen Befehle sind Stubs und melden `not implemented -yet` (Exit-Code ≠ 0). +## Erste Schritte + +```console +talk doctor # Was ist da, was fehlt? Eine Zeile pro Punkt. +talk test # Hörst du etwas? Welche Stimme war das? +talk setup # Stimme aussuchen, Ruhezeit, fertig. +talk "Hallo Welt" # Ab jetzt: Skripte und Agenten rufen genau das. +``` + +Ohne jeden Key funktioniert talk sofort: Es nutzt die kostenlose Edge- +Stimme von Microsoft (Internet nötig) und sonst die Stimme des +Betriebssystems. Wer eine Premium-Stimme will, setzt `TALK_ELEVENLABS_KEY` +oder `TALK_OPENAI_KEY` als Umgebungsvariable — Keys gehören nie in die +config.toml. + +### Diagnose und Einrichtung + +- **`talk doctor`** berichtet zeilenweise mit ✔ (ok), ⚠ (Hinweis), ✘ + (Fehler): Config-Datei, Daemon (läuft / startet beim nächsten + Sprechbefehl / fremder Dienst auf dem Port), Audio-Ausgabe (oto + initialisierbar? `null` = kein Ton), jede Provider-Stufe (Key vorhanden, + Endpunkt, Default-Stimme; OS-Stimme: welcher Mechanismus gefunden + wurde), Mute, Gesetze inkl. aktiver Ruhezeit. Exit 0 ohne ✘, sonst 1; + ein nicht laufender Daemon ist ⚠. `--probe` macht echte Synthese-Proben + (Netz), `--json` liefert die Liste maschinenlesbar. Auf Windows-Konsolen + stehen `[OK]`/`[WARN]`/`[FAIL]` statt der Symbole. +- **`talk test`** spricht einen festen Testsatz direkt in diesem Prozess + (nicht über den Daemon; Mute und Ruhezeit gelten hier nicht, ein aktives + Mute wird genannt) und zeigt, welche Stufe gesprochen hat und wie lange + Synthese und Wiedergabe gedauert haben. +- **`talk voices`** listet den kuratierten Stimmen-Katalog (ElevenLabs- + Premade, OpenAI, Edge-Neural je Locale) als Tabelle; `*` markiert die + Stimme, die talk ohne `--voice` nimmt (config `voice..` + oder Repo-Default). Config-Einträge außerhalb des Katalogs erscheinen + als eigene Zeile. Jede Voice-ID des Providers funktioniert auch ohne + Katalog-Eintrag. +- **`talk setup`** fragt im Terminal: Keys (nur Hinweis — ohne Key läuft + die Edge-Stimme), Stimme für Deutsch und Englisch der aktiven Stufe + (Nummer aus dem Katalog oder Voice-ID, probehören, übernehmen), + Ruhezeit. Dann schreibt es die config.toml — eine bestehende Datei wird + zusammengeführt (andere Einstellungen bleiben) und als `.bak` gesichert + — und spricht einen Testsatz. `q` bricht jederzeit ab, geschrieben wird + erst ganz am Ende. Ohne Terminal (Pipe, Agent) stellt setup keine Fragen + und zeigt nur die wirksame Konfiguration. ## Daemon diff --git a/cmd/talk/cli.go b/cmd/talk/cli.go index aca67d0..6365a25 100644 --- a/cmd/talk/cli.go +++ b/cmd/talk/cli.go @@ -81,24 +81,31 @@ var commands = []command{ handler: runCancel, }, { - name: "voices", - short: "Verfügbare Stimmen anzeigen", - detail: "Listet die Stimmen aller Provider (Defaults + Config-Overrides) als Tabelle.", + name: "voices", + short: "Verfügbare Stimmen anzeigen", + detail: "Listet bekannte Stimmen je Provider und Sprache als Tabelle; „*“ markiert die Stimme, die talk ohne --voice nimmt (config voice.. oder Repo-Default). Filter: --provider, --lang. --json für Maschinen. Jede Voice-ID des Providers funktioniert auch ohne Katalog-Eintrag.", + register: addVoicesFlags, + handler: runVoices, }, { - name: "test", - short: "Test-Ansage ausgeben", - detail: "Spricht eine feste Testphrase und zeigt den verwendeten Provider samt Latenz.", + name: "test", + short: "Test-Ansage ausgeben", + detail: "Spricht eine feste Testphrase direkt in diesem Prozess (ohne Daemon, auch bei Mute) und zeigt, welcher Provider gesprochen hat und wie lange Synthese und Wiedergabe gedauert haben. --lang de|en wählt die Phrase, --provider/--voice pinnen wie bei say.", + register: addTestFlags, + handler: runTest, }, { - name: "doctor", - short: "Setup-Diagnose anzeigen", - detail: "Prüft Daemon, Audio-Sink, Provider-Keys und Config und berichtet zeilenweise mit ✔/✘/⚠.", + name: "doctor", + short: "Setup-Diagnose anzeigen", + detail: "Prüft Config, Daemon, Audio-Ausgabe, jede Provider-Stufe, Mute und Gesetze und berichtet zeilenweise mit ✔ (ok), ⚠ (Hinweis), ✘ (Fehler). Exit 0 ohne ✘, sonst 1; ein nicht laufender Daemon ist ⚠, kein Fehler. --probe macht echte Synthese-Proben bei allen verfügbaren Providern (Netz). --json für Maschinen.", + register: addDoctorFlags, + handler: runDoctor, }, { - name: "setup", - short: "Geführte Ersteinrichtung (Wizard)", - detail: "Interaktiver Wizard: Provider-Keys, Stimmen probehören, config.toml schreiben. Ohne TTY: plain.", + name: "setup", + short: "Geführte Ersteinrichtung", + detail: "Fragt in wenigen Schritten nach dem, was talk zum Loslegen braucht: Keys (optional — ohne Key spricht die kostenlose Edge-Stimme), Stimme je Sprache mit Probehören, Ruhezeit — und schreibt die config.toml (bestehende Datei wird zusammengeführt, Sicherung .bak). Ohne Terminal: zeigt nur die wirksame Konfiguration.", + handler: runSetup, }, } @@ -122,6 +129,32 @@ func addSayFlags(fs *flag.FlagSet) *sayOptions { return o } +// addVoicesFlags registriert die Flags von voices (Filter + JSON). +func addVoicesFlags(fs *flag.FlagSet) *sayOptions { + o := &sayOptions{} + fs.StringVar(&o.provider, "provider", "", "nur dieser Provider: elevenlabs | openai | edge | osbuiltin") + fs.StringVar(&o.lang, "lang", "", "nur diese Sprache: de | en") + fs.BoolVar(&o.jsonOut, "json", false, "maschinenlesbare JSON-Ausgabe") + return o +} + +// addTestFlags registriert die Flags von test (Phrase + Pins). +func addTestFlags(fs *flag.FlagSet) *sayOptions { + o := &sayOptions{} + fs.StringVar(&o.lang, "lang", "", "Sprache der Testphrase: de | en (Default de)") + fs.StringVar(&o.provider, "provider", "", "Provider pinnen: elevenlabs | openai | edge | osbuiltin") + fs.StringVar(&o.voice, "voice", "", "Stimme als provider-spezifische ID") + return o +} + +// addDoctorFlags registriert die Flags von doctor. +func addDoctorFlags(fs *flag.FlagSet) *sayOptions { + o := &sayOptions{} + fs.BoolVar(&o.jsonOut, "json", false, "maschinenlesbare JSON-Ausgabe") + fs.BoolVar(&o.probe, "probe", false, "echte Synthese-Probe bei jedem verfügbaren Provider (Netz-Aufrufe)") + return o +} + // run ist der Einstiegspunkt der CLI (Argumente ohne Programmnamen). Alle // Ausgaben gehen auf die übergebenen Writer, stdin ist als Reader injiziert // — damit ist run direkt und ohne Prozessstart testbar (auch der stdin- diff --git a/cmd/talk/cli_test.go b/cmd/talk/cli_test.go index ef400e6..9d0454c 100644 --- a/cmd/talk/cli_test.go +++ b/cmd/talk/cli_test.go @@ -14,14 +14,6 @@ var wantSubcommands = []string{ "say", "wrap", "mute", "unmute", "status", "cancel", "voices", "test", "doctor", "setup", } -// wantStubCommands ist wantSubcommands ohne die implementierten Befehle — -// say (Task 02/04), mute/unmute/status/cancel (Task 04, Daemon-Aufrufe) -// und wrap (Task 07, Befehlsummichtung mit Ansage) sind real; die -// restlichen bleiben Stubs bis zu ihren Tasks. -var wantStubCommands = []string{ - "voices", "test", "doctor", "setup", -} - // runCLI ist der Test-Einstieg in run mit leerem stdin (kein Text über // Pipe) — dem üblichen Aufruf ohne Eingabe. func runCLI(argv []string, stdout, stderr io.Writer) int { @@ -70,27 +62,6 @@ func TestVersionStringMitCommit(t *testing.T) { } } -func TestSubcommandStubs(t *testing.T) { - for _, name := range wantStubCommands { - t.Run(name, func(t *testing.T) { - var out, errBuf bytes.Buffer - code := runCLI([]string{name}, &out, &errBuf) - if code == exitOK { - t.Errorf("talk %s: Exit-Code 0, want non-zero für Stub", name) - } - if !strings.Contains(errBuf.String(), "not implemented yet") { - t.Errorf("talk %s: stderr = %q, will Hinweis auf nicht implementiert", name, errBuf.String()) - } - if !strings.Contains(errBuf.String(), name) { - t.Errorf("talk %s: stderr nennt den Befehlsnamen nicht: %q", name, errBuf.String()) - } - if out.Len() != 0 { - t.Errorf("talk %s: unerwartete stdout-Ausgabe %q", name, out.String()) - } - }) - } -} - func TestSayHelpZeigtFlags(t *testing.T) { var out, errBuf bytes.Buffer code := runCLI([]string{"say", "--help"}, &out, &errBuf) diff --git a/cmd/talk/doctor.go b/cmd/talk/doctor.go new file mode 100644 index 0000000..3673232 --- /dev/null +++ b/cmd/talk/doctor.go @@ -0,0 +1,311 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "code.m3ta.dev/m3tam3re/talk/internal/api" + "code.m3ta.dev/m3tam3re/talk/internal/audio" + "code.m3ta.dev/m3tam3re/talk/internal/config" + "code.m3ta.dev/m3tam3re/talk/internal/daemon" + "code.m3ta.dev/m3tam3re/talk/internal/provider" + "code.m3ta.dev/m3tam3re/talk/internal/queue" + "code.m3ta.dev/m3tam3re/talk/internal/speak" +) + +// Status einer doctor-Prüfung. +const ( + statusOK = "ok" // ✔ in Ordnung + statusWarn = "warn" // ⚠ Hinweis — funktioniert, aber eingeschränkt oder beachtenswert + statusFail = "fail" // ✘ Fehler — so wird nichts zu hören sein (Exit 1) +) + +// check ist eine Zeile des doctor-Berichts (auch die JSON-Form). +type check struct { + Check string `json:"check"` + Status string `json:"status"` + Detail string `json:"detail"` +} + +// probeTimeout ist das Budget je Synthese-Probe (--probe). +const probeTimeout = 20 * time.Second + +// runDoctor implementiert `talk doctor`: eine Zeile je Prüfpunkt — Config, +// Daemon, Audio, jede Provider-Stufe, Mute, Gesetze — mit klarem Satz, was +// Sache ist und was zu tun wäre. Exit 0 ohne ✘, sonst 1; ein nicht +// laufender Daemon ist ⚠ (er startet beim nächsten Sprechbefehl). --probe +// macht echte Synthese-Proben (Netz); --json liefert die Liste als JSON. +func runDoctor(args []string, opts *sayOptions, _ io.Reader, stdout, stderr io.Writer) int { + if len(args) > 0 { + fmt.Fprintln(stderr, "talk doctor: keine Argumente erwartet (Flags: --probe, --json)") + return exitUsage + } + checks := doctorChecks(opts.probe) + failed := false + for _, c := range checks { + if c.Status == statusFail { + failed = true + } + } + if opts.jsonOut { + enc := json.NewEncoder(stdout) + enc.SetIndent("", " ") + _ = enc.Encode(checks) + } else { + for _, c := range checks { + fmt.Fprintf(stdout, "%s %-11s %s\n", symbol(c.Status), c.Check, c.Detail) + } + if failed { + fmt.Fprintln(stdout, "\nMindestens ein Punkt verhindert Ton (✘). Behebe ihn und prüfe mit `talk test`.") + } else { + fmt.Fprintln(stdout, "\nAlles bereit. Probiere: talk test") + } + } + if failed { + return exitFailure + } + return exitOK +} + +// symbol rendert den Status: Unicode-Zeichen, auf Windows-Konsolen (oft +// ohne UTF-8) kurze Wörter. +func symbol(status string) string { + win := runtime.GOOS == "windows" + switch status { + case statusOK: + if win { + return "[OK] " + } + return "✔" + case statusWarn: + if win { + return "[WARN]" + } + return "⚠" + default: + if win { + return "[FAIL]" + } + return "✘" + } +} + +// doctorChecks führt alle Prüfungen aus. Jede Prüfung ist unabhängig: Ein +// Fehler in einer (z. B. kaputte Config) verhindert die anderen nicht — +// dann laufen sie mit Defaults, damit der Bericht vollständig bleibt. +func doctorChecks(probe bool) []check { + var out []check + + // 1. Config + cfg, cfgCheck := checkConfig() + out = append(out, cfgCheck) + + // 2. Daemon + out = append(out, checkDaemon(cfg)) + + // 3. Audio + out = append(out, checkAudio(cfg)) + + // 4. Provider-Stufen (+ 5. Gesamt-Verfügbarkeit) + out = append(out, checkProviders(cfg, probe)...) + + // 6. Mute + out = append(out, checkMute()) + + // 7. Gesetze + out = append(out, checkLaws(cfg)) + + return out +} + +func checkConfig() (config.Config, check) { + path, perr := config.Path(getenv) + cfg, err := config.Load(config.Options{Getenv: getenv}) + switch { + case err != nil: + // Mit Defaults weiterprüfen, aber den Fehler klar nennen. + cfg, _ = config.Load(config.Options{Getenv: func(string) string { return "" }}) + return cfg, check{"Config", statusFail, fmt.Sprintf("%v — bitte beheben, talk läuft sonst nicht", err)} + case perr != nil: + return cfg, check{"Config", statusWarn, "kein Config-Verzeichnis (HOME bzw. APPDATA fehlt) — Defaults + Env; Mute und Daemon-Dateien deaktiviert"} + } + if _, statErr := os.Stat(path); statErr != nil { + return cfg, check{"Config", statusOK, fmt.Sprintf("keine Datei unter %s — Defaults gelten (talk setup legt eine an)", path)} + } + return cfg, check{"Config", statusOK, path + " geladen"} +} + +func checkDaemon(cfg config.Config) check { + client := api.NewClient(cfg.Server.Port) + ctx, cancel := context.WithTimeout(context.Background(), daemonProbeTimeout) + defer cancel() + st, err := client.Status(ctx) + if err == nil { + detail := fmt.Sprintf("läuft unter %s (version %s, %d in der Queue)", client.BaseURL, st.Version, st.Queue) + if dir, derr := config.Dir(getenv); derr == nil { + detail += ", Log: " + filepath.Join(dir, daemon.LogFileName) + } + return check{"Daemon", statusOK, detail} + } + var httpErr *api.HTTPError + if errors.As(err, &httpErr) { + return check{"Daemon", statusFail, fmt.Sprintf("unter %s antwortet ein fremder Dienst (HTTP %d) — anderen Port wählen: [server] port bzw. TALK_PORT", client.BaseURL, httpErr.Status)} + } + if cfg.Daemon.Autostart { + return check{"Daemon", statusWarn, fmt.Sprintf("läuft nicht (%s) — startet beim nächsten Sprechbefehl von selbst", client.BaseURL)} + } + return check{"Daemon", statusWarn, fmt.Sprintf("läuft nicht (%s), Autostart ist aus — `talk daemon start` oder Dienst einrichten; bis dahin spricht talk direkt", client.BaseURL)} +} + +func checkAudio(cfg config.Config) check { + if cfg.Audio.Sink == config.SinkNull { + return check{"Audio", statusWarn, "audio.sink = null — kein Ton (für CI/Server gedacht; TALK_AUDIO_SINK=oto für Wiedergabe)"} + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := audio.Check(ctx, cfg.Audio.Sink); err != nil { + return check{"Audio", statusFail, fmt.Sprintf("%v — kein Audiogerät oder Audio-Bibliothek fehlt (Linux: PulseAudio/ALSA)", err)} + } + return check{"Audio", statusOK, "Audiogerät bereit (oto)"} +} + +func checkProviders(cfg config.Config, probe bool) []check { + var out []check + engine, err := speak.New(cfg, getenv, io.Discard) + if err != nil { + return []check{{"Provider", statusFail, err.Error()}} + } + available := 0 + for _, p := range engine.Chain().Tiers() { + c := checkTier(p, cfg) + if p.Available(context.Background()) { + available++ + if probe { + c.Detail += "; " + probeTier(p) + } + } + out = append(out, c) + } + if available == 0 { + out = append(out, check{"Provider", statusFail, "keine Stufe verfügbar — ohne Key und ohne OS-Stimme kann talk nicht sprechen (espeak-ng installieren oder TALK_ELEVENLABS_KEY/TALK_OPENAI_KEY setzen)"}) + } else if cfg.Provider != "" { + out = append(out, check{"Provider", statusOK, fmt.Sprintf("Kette auf %q gepinnt (config provider) — kein Fallback", cfg.Provider)}) + } + return out +} + +func checkTier(p provider.Provider, cfg config.Config) check { + name := p.Name() + switch name { + case "elevenlabs", "openai": + envKey := provider.EnvElevenLabsKey + if name == "openai" { + envKey = provider.EnvOpenAIKey + } + if !p.Available(context.Background()) { + return check{name, statusWarn, fmt.Sprintf("kein Key (%s) — Stufe wird übersprungen", envKey)} + } + detail := fmt.Sprintf("Key gesetzt (%s)", envKey) + if ep := cfg.Providers[name].Endpoint; ep != "" { + detail += ", Endpunkt " + ep + } + if v := provider.DefaultVoice(name, "de", cfg.Voice); v != "" { + detail += ", Stimme de: " + voiceLabel(name, v) + } + return check{name, statusOK, detail} + case "edge": + if !p.Available(context.Background()) { + return check{name, statusWarn, "kein Endpunkt konfiguriert — Stufe wird übersprungen"} + } + return check{name, statusOK, "kostenlos, ohne Key (braucht Internet), Stimme de: " + voiceLabel(name, provider.DefaultVoice(name, "de", cfg.Voice))} + case "osbuiltin": + desc := "OS-Stimme" + if d, ok := p.(interface{ Describe() string }); ok { + desc = d.Describe() + } + if !p.Available(context.Background()) { + return check{name, statusWarn, desc + " — letzte Stufe fehlt; ohne Netz bleibt talk stumm"} + } + if strings.Contains(desc, "nur Direktansage") { + return check{name, statusWarn, desc} + } + return check{name, statusOK, desc} + default: + if p.Available(context.Background()) { + return check{name, statusOK, "verfügbar"} + } + return check{name, statusWarn, "nicht verfügbar"} + } +} + +// voiceLabel rendert eine Voice-ID mit Katalog-Namen, falls bekannt. +func voiceLabel(prov, id string) string { + if n := provider.VoiceName(prov, id); n != "" { + return fmt.Sprintf("%s (%s)", n, id) + } + return id +} + +// probeTier macht eine echte Synthese-Probe (kein Abspielen) und meldet +// Bytes und Dauer bzw. den Fehler. +func probeTier(p provider.Provider) string { + ctx, cancel := context.WithTimeout(context.Background(), probeTimeout) + defer cancel() + start := time.Now() + data, err := p.Synthesize(ctx, "Test", "de") + switch { + case errors.Is(err, provider.ErrNoByteOutput): + return "Probe: liefert keine Bytes (spricht nur direkt) — ok" + case err != nil: + return "Probe FEHLGESCHLAGEN: " + err.Error() + default: + return fmt.Sprintf("Probe ok: %d Bytes in %s", len(data), time.Since(start).Round(time.Millisecond)) + } +} + +func checkMute() check { + dir, err := config.Dir(getenv) + if err != nil { + return check{"Mute", statusOK, "aus (kein Config-Dir, Mute nicht verfügbar)"} + } + if (queue.MuteStore{Path: filepath.Join(dir, queue.MuteFileName)}).Muted() { + return check{"Mute", statusWarn, "AN — alle Ansagen werden verworfen, bis `talk unmute`"} + } + return check{"Mute", statusOK, "aus"} +} + +func checkLaws(cfg config.Config) check { + parts := []string{ + fmt.Sprintf("rate-limit %s", durationOrOff(cfg.Queue.RateLimit)), + fmt.Sprintf("max. %d Zeichen", cfg.Queue.MaxText), + fmt.Sprintf("max. Wartezeit %s", durationOrOff(cfg.Queue.MaxAge)), + } + status := statusOK + if cfg.QuietHours == "" { + parts = append(parts, "Ruhezeit aus") + } else { + q, err := queue.ParseQuietHours(cfg.QuietHours) + if err == nil && q.Active(time.Now()) { + status = statusWarn + parts = append(parts, fmt.Sprintf("Ruhezeit %s AKTIV bis %s (modus %s) — Ansagen werden gerade verworfen", cfg.QuietHours, q.Until(time.Now()).Format("15:04"), cfg.QuietMode)) + } else { + parts = append(parts, fmt.Sprintf("Ruhezeit %s (modus %s)", cfg.QuietHours, cfg.QuietMode)) + } + } + return check{"Gesetze", status, strings.Join(parts, ", ")} +} + +func durationOrOff(d time.Duration) string { + if d <= 0 { + return "aus" + } + return d.String() +} diff --git a/cmd/talk/doctor_test.go b/cmd/talk/doctor_test.go new file mode 100644 index 0000000..d45f9e4 --- /dev/null +++ b/cmd/talk/doctor_test.go @@ -0,0 +1,116 @@ +package main + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func checkByName(t *testing.T, checks []check, name string) check { + t.Helper() + for _, c := range checks { + if c.Check == name { + return c + } + } + t.Fatalf("Prüfung %q fehlt in %+v", name, checks) + return check{} +} + +// TestDoctorOhneDaemon: toter Daemon ist ⚠ (kein Fehler), sink null ist ⚠, +// Cloud-Stufen ohne Key ⚠, edge ✔ — Exit 0, Abschlusszeile „Alles bereit". +func TestDoctorOhneDaemon(t *testing.T) { + root := t.TempDir() + setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null", "XDG_CONFIG_HOME": root}) + var out, errBuf bytes.Buffer + code := runCLI([]string{"doctor"}, &out, &errBuf) + if code != exitOK { + t.Fatalf("talk doctor: Exit %d, stdout %q, stderr %q", code, out.String(), errBuf.String()) + } + for _, want := range []string{"⚠ Daemon", "läuft nicht", "⚠ Audio", "⚠ elevenlabs", "TALK_ELEVENLABS_KEY", "✔ edge", "✔ Mute", "✔ Gesetze", "Alles bereit"} { + if !strings.Contains(out.String(), want) { + t.Errorf("stdout ohne %q:\n%s", want, out.String()) + } + } +} + +// TestDoctorJSONUndMute: --json liefert die Prüfliste; eine Mute-Datei +// macht Mute zu warn; ein Key macht die Stufe ok. +func TestDoctorJSONUndMute(t *testing.T) { + root := t.TempDir() + setGetenv(t, map[string]string{ + "TALK_AUDIO_SINK": "null", + "XDG_CONFIG_HOME": root, + "TALK_ELEVENLABS_KEY": "k", + }) + if err := os.MkdirAll(filepath.Join(root, "talk"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "talk", "mute"), []byte("true\n"), 0o644); err != nil { + t.Fatal(err) + } + var out, errBuf bytes.Buffer + if code := runCLI([]string{"doctor", "--json"}, &out, &errBuf); code != exitOK { + t.Fatalf("talk doctor --json: Exit %d, stderr %q", code, errBuf.String()) + } + var checks []check + if err := json.Unmarshal(out.Bytes(), &checks); err != nil { + t.Fatalf("JSON = %q (%v)", out.String(), err) + } + if c := checkByName(t, checks, "Mute"); c.Status != statusWarn || !strings.Contains(c.Detail, "unmute") { + t.Errorf("Mute = %+v, want warn mit unmute-Hinweis", c) + } + if c := checkByName(t, checks, "elevenlabs"); c.Status != statusOK || !strings.Contains(c.Detail, "Rachel") { + t.Errorf("elevenlabs = %+v, want ok mit Default-Stimme", c) + } + if c := checkByName(t, checks, "Daemon"); c.Status != statusWarn { + t.Errorf("Daemon = %+v, want warn", c) + } +} + +// TestDoctorFremderDienstAufPort: Antwortet unter dem Port etwas anderes als +// talk, ist das ✘ mit Port-Hinweis — Exit 1. +func TestDoctorFremderDienstAufPort(t *testing.T) { + fremd := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + t.Cleanup(fremd.Close) + port := fremd.URL[strings.LastIndex(fremd.URL, ":")+1:] + setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null", "TALK_PORT": port}) + var out, errBuf bytes.Buffer + code := runCLI([]string{"doctor"}, &out, &errBuf) + if code != exitFailure { + t.Fatalf("talk doctor (fremder Dienst): Exit %d, want 1\n%s", code, out.String()) + } + if !strings.Contains(out.String(), "✘ Daemon") || !strings.Contains(out.String(), "fremder Dienst") || !strings.Contains(out.String(), "TALK_PORT") { + t.Errorf("stdout = %q", out.String()) + } +} + +// TestDoctorKaputteConfig: Eine unlesbare config.toml ist ✘, die übrigen +// Prüfungen laufen trotzdem (mit Defaults) — der Bericht bleibt vollständig. +func TestDoctorKaputteConfig(t *testing.T) { + root := t.TempDir() + setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null", "XDG_CONFIG_HOME": root}) + if err := os.MkdirAll(filepath.Join(root, "talk"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "talk", "config.toml"), []byte("audio = [broken\n"), 0o644); err != nil { + t.Fatal(err) + } + var out, errBuf bytes.Buffer + code := runCLI([]string{"doctor"}, &out, &errBuf) + if code != exitFailure || !strings.Contains(out.String(), "✘ Config") { + t.Errorf("Exit %d, stdout %q — want ✘ Config und Exit 1", code, out.String()) + } + for _, want := range []string{"Daemon", "edge", "Mute", "Gesetze"} { + if !strings.Contains(out.String(), want) { + t.Errorf("Prüfung %q fehlt trotz Config-Fehler:\n%s", want, out.String()) + } + } +} diff --git a/cmd/talk/say.go b/cmd/talk/say.go index f132b84..05e6093 100644 --- a/cmd/talk/say.go +++ b/cmd/talk/say.go @@ -27,6 +27,12 @@ type sayOptions struct { voice string lang string provider string + + // Diagnose-Flags (doctor, voices): maschinenlesbare Ausgabe und echte + // Netz-Proben. Liegen in derselben Struct, damit die Befehlstabelle + // eine Flag-Signatur behält; say ignoriert sie. + jsonOut bool + probe bool } // runSay ist der say-Eingangspfad: Text aus Argumenten oder stdin sammeln @@ -100,7 +106,7 @@ func sayDirect(cfg config.Config, text string, opts *sayOptions, info, stderr io fmt.Fprintf(stderr, "talk: %v\n", err) return exitFailure } - err = engine.Speak(context.Background(), speak.Request{ + _, err = engine.Speak(context.Background(), speak.Request{ Text: text, Lang: opts.lang, Provider: opts.provider, diff --git a/cmd/talk/setup.go b/cmd/talk/setup.go new file mode 100644 index 0000000..41a179f --- /dev/null +++ b/cmd/talk/setup.go @@ -0,0 +1,380 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/BurntSushi/toml" + + "code.m3ta.dev/m3tam3re/talk/internal/api" + "code.m3ta.dev/m3tam3re/talk/internal/config" + "code.m3ta.dev/m3tam3re/talk/internal/provider" + "code.m3ta.dev/m3tam3re/talk/internal/queue" + "code.m3ta.dev/m3tam3re/talk/internal/speak" +) + +// setupInteractive entscheidet, ob der Wizard Fragen stellen darf (stdin +// ist ein Terminal). Tests überschreiben die Seam und füttern Antworten +// über stdin. +var setupInteractive = isCharDevice + +// setupSample ist der Probehör-Satz je Sprache. +var setupSample = map[string]string{ + "de": "Hallo, so klinge ich. Build fertig.", + "en": "Hello, this is how I sound. Build finished.", +} + +// runSetup implementiert `talk setup`: wenige Fragen, dann config.toml. +// +// 1. Daemon/Version, Audio. +// 2. Keys: gefunden → Premium; keiner → Erklärung, dass die kostenlose +// Edge-Stimme (Internet) bzw. die OS-Stimme ohnehin funktionieren. +// 3. Stimme je Sprache (de, en) für die aktive Stufe: Nummer aus dem +// Katalog wählen, probehören, übernehmen. +// 4. Ruhezeit (optional). +// 5. config.toml schreiben (bestehende zusammenführen, .bak), Test-Satz. +// +// Ohne Terminal (Pipe, Agent) stellt setup keine Fragen: Es zeigt die +// wirksame Konfiguration und endet mit Exit 0 — nichts bricht, nichts +// wartet. Abbruch mit Ctrl-C oder leerer Eingabe bei „q": nichts +// halbfertig geschrieben (geschrieben wird erst ganz am Ende). +func runSetup(args []string, _ *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int { + if len(args) > 0 { + fmt.Fprintln(stderr, "talk setup: keine Argumente erwartet") + return exitUsage + } + cfg, err := config.Load(config.Options{Getenv: getenv}) + if err != nil { + fmt.Fprintf(stderr, "talk setup: %v\nBitte zuerst die Config-Datei reparieren oder umbenennen.\n", err) + return exitFailure + } + path, perr := config.Path(getenv) + if perr != nil { + fmt.Fprintf(stderr, "talk setup: %v — ohne Config-Verzeichnis kann setup nichts speichern\n", perr) + return exitFailure + } + + if !setupInteractive(stdin) { + fmt.Fprintln(stdout, "talk setup braucht ein Terminal, um Fragen zu stellen. Wirksame Konfiguration:") + printEffective(stdout, cfg, path) + return exitOK + } + + w := &wizard{in: bufio.NewReader(stdin), out: stdout, err: stderr, cfg: cfg} + fmt.Fprintln(stdout, "talk einrichten — ein paar Fragen, Enter übernimmt den Vorschlag in [Klammern], q bricht ab.") + fmt.Fprintln(stdout) + + // 1. Umgebung + fmt.Fprintf(stdout, "Audio-Ausgabe: %s\n", audioSummary(cfg)) + client := api.NewClient(cfg.Server.Port) + if daemonAlive(client, daemonProbeTimeout) { + fmt.Fprintf(stdout, "Daemon: läuft unter %s\n", client.BaseURL) + } else { + fmt.Fprintln(stdout, "Daemon: startet beim ersten Sprechbefehl von selbst") + } + + // 2. Keys + engine, err := speak.New(cfg, getenv, io.Discard) + if err != nil { + fmt.Fprintf(stderr, "talk setup: %v\n", err) + return exitFailure + } + active := engine.ActiveProvider("") + fmt.Fprintln(stdout) + switch { + case getenv(provider.EnvElevenLabsKey) != "": + fmt.Fprintln(stdout, "Stimmen: ElevenLabs-Key gefunden (TALK_ELEVENLABS_KEY) — Premium-Stimme.") + case getenv(provider.EnvOpenAIKey) != "": + fmt.Fprintln(stdout, "Stimmen: OpenAI-Key gefunden (TALK_OPENAI_KEY).") + default: + fmt.Fprintln(stdout, "Stimmen: kein Key gefunden — das ist ok. talk nutzt die kostenlose Edge-Stimme von") + fmt.Fprintln(stdout, " Microsoft (braucht Internet) und sonst die Stimme deines Betriebssystems.") + fmt.Fprintln(stdout, " Für Premium-Stimmen später TALK_ELEVENLABS_KEY oder TALK_OPENAI_KEY als") + fmt.Fprintln(stdout, " Umgebungsvariable setzen (nie in die config.toml).") + } + if active == speak.NoProvider { + fmt.Fprintln(stderr, "talk setup: derzeit ist keine Stimme verfügbar — `talk doctor` zeigt, was fehlt. Einstellungen werden trotzdem gespeichert.") + } else { + fmt.Fprintf(stdout, "Aktive Stufe: %s\n", active) + } + + // 3. Stimmen + voices := map[string]string{} + if active != speak.NoProvider && active != "osbuiltin" { + for _, lang := range []string{"de", "en"} { + id, ok := w.chooseVoice(engine, active, lang) + if !ok { + fmt.Fprintln(stdout, "Abgebrochen — nichts geschrieben.") + return exitOK + } + if id != "" { + voices[lang] = id + } + } + } else if active == "osbuiltin" { + fmt.Fprintln(stdout, "\nDie OS-Stimme wählt dein Betriebssystem; es gibt hier nichts einzustellen.") + } + + // 4. Ruhezeit + fmt.Fprintln(stdout) + quiet, ok := w.askQuietHours(cfg.QuietHours) + if !ok { + fmt.Fprintln(stdout, "Abgebrochen — nichts geschrieben.") + return exitOK + } + + // 5. Schreiben + changes := map[string]any{} + if len(voices) > 0 { + changes["voice"] = map[string]any{active: toAny(voices)} + } + changes["quiet_hours"] = quiet + backup, err := writeConfig(path, changes) + if err != nil { + fmt.Fprintf(stderr, "talk setup: config schreiben: %v\n", err) + return exitFailure + } + fmt.Fprintln(stdout) + fmt.Fprintf(stdout, "Gespeichert: %s\n", path) + if backup != "" { + fmt.Fprintf(stdout, "Sicherung: %s\n", backup) + } + + // Abschluss: Testsatz über die neue Config. + if active != speak.NoProvider { + fmt.Fprintln(stdout, "\nTestsatz …") + if cfg2, err := config.Load(config.Options{Getenv: getenv}); err == nil { + if eng2, err := speak.New(cfg2, getenv, io.Discard); err == nil { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + if out, err := eng2.Speak(ctx, speak.Request{Text: testPhrase["de"], Lang: "de"}); err != nil { + fmt.Fprintf(stderr, "talk setup: Testsatz fehlgeschlagen: %v (siehe talk doctor)\n", err) + } else { + fmt.Fprintf(stdout, "Gesprochen über %s.\n", out.Provider) + } + } + } + } + fmt.Fprintln(stdout, "\nFertig. Probiere: talk \"Hallo Welt\" — Status: talk status — Stumm: talk mute") + return exitOK +} + +// wizard hält Ein-/Ausgabe des Setups. +type wizard struct { + in *bufio.Reader + out io.Writer + err io.Writer + cfg config.Config +} + +// ask stellt eine Frage mit Vorschlag; Enter liefert den Vorschlag, „q" +// bricht ab (ok=false). EOF zählt als Abbruch. +func (w *wizard) ask(prompt, def string) (string, bool) { + if def != "" { + fmt.Fprintf(w.out, "%s [%s]: ", prompt, def) + } else { + fmt.Fprintf(w.out, "%s: ", prompt) + } + line, err := w.in.ReadString('\n') + if err != nil && line == "" { + return "", false + } + line = strings.TrimSpace(line) + if line == "q" || line == "Q" { + return "", false + } + if line == "" { + return def, true + } + return line, true +} + +// chooseVoice lässt eine Stimme für provider/lang wählen: nummerierte +// Katalog-Liste, aktueller Default markiert; Probehören; übernehmen oder +// neu wählen. Rückgabe: gewählte ID ("" = Default behalten) und ok=false +// bei Abbruch. +func (w *wizard) chooseVoice(engine *speak.Engine, prov, lang string) (string, bool) { + langName := map[string]string{"de": "Deutsch", "en": "Englisch"}[lang] + current := provider.DefaultVoice(prov, lang, w.cfg.Voice) + var list []provider.Voice + for _, v := range provider.Catalog() { + if v.Provider == prov && v.Lang == lang { + list = append(list, v) + } + } + fmt.Fprintf(w.out, "\nStimme für %s (%s):\n", langName, prov) + defIdx := "" + for i, v := range list { + mark := " " + if v.ID == current { + mark = "*" + defIdx = strconv.Itoa(i + 1) + } + fmt.Fprintf(w.out, " %s %d) %s\n", mark, i+1, v.Name) + } + if defIdx == "" && current != "" { + fmt.Fprintf(w.out, " * aktuell: %s (aus config.toml)\n", current) + } + for { + answer, ok := w.ask("Nummer wählen, oder eine Voice-ID eintippen", defIdx) + if !ok { + return "", false + } + id := answer + if n, err := strconv.Atoi(answer); err == nil { + if n < 1 || n > len(list) { + fmt.Fprintf(w.out, "Bitte 1–%d.\n", len(list)) + continue + } + id = list[n-1].ID + } + if id == "" { + return "", true + } + hear, ok := w.ask("Probehören?", "J") + if !ok { + return "", false + } + if strings.EqualFold(hear, "j") || strings.EqualFold(hear, "ja") || strings.EqualFold(hear, "y") { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + _, err := engine.Speak(ctx, speak.Request{Text: setupSample[lang], Lang: lang, Provider: prov, Voice: id}) + cancel() + if err != nil { + fmt.Fprintf(w.err, "Probehören fehlgeschlagen: %v\n", err) + } + } + take, ok := w.ask("Übernehmen?", "J") + if !ok { + return "", false + } + if strings.EqualFold(take, "j") || strings.EqualFold(take, "ja") || strings.EqualFold(take, "y") { + if id == current { + return "", true // Default behalten — nichts in die Datei schreiben + } + return id, true + } + } +} + +// askQuietHours fragt das Ruhezeit-Fenster ab (leer = keine). +func (w *wizard) askQuietHours(current string) (string, bool) { + fmt.Fprintln(w.out, "Ruhezeit: In diesem Fenster bleibt talk still (z. B. 22:00-07:00). Leer lassen = keine Ruhezeit.") + def := current + if def == "" { + def = "keine" + } + for { + answer, ok := w.ask("Ruhezeit", def) + if !ok { + return "", false + } + if answer == "keine" || answer == "-" || answer == "aus" { + return "", true + } + if _, err := queue.ParseQuietHours(answer); err != nil { + fmt.Fprintf(w.out, "Format HH:MM-HH:MM, z. B. 22:00-07:00 (%v).\n", err) + continue + } + return answer, true + } +} + +// writeConfig führt changes in die bestehende config.toml ein (fehlende +// Datei: neu) und schreibt sie atomar; eine vorhandene Datei wird vorher +// als .bak gesichert. Verschachtelte Tabellen (voice.) werden +// zusammengeführt, nicht ersetzt. Rückgabe: Pfad der Sicherung oder "". +func writeConfig(path string, changes map[string]any) (backup string, err error) { + existing := map[string]any{} + if raw, rerr := os.ReadFile(path); rerr == nil { + if _, derr := toml.Decode(string(raw), &existing); derr != nil { + return "", fmt.Errorf("bestehende %s unlesbar: %w", path, derr) + } + backup = path + ".bak" + if werr := os.WriteFile(backup, raw, 0o644); werr != nil { + return "", werr + } + } else if !os.IsNotExist(rerr) { + return "", rerr + } + mergeInto(existing, changes) + // Leere Ruhezeit heißt: Schlüssel raus, statt "" zu schreiben. + if v, ok := existing["quiet_hours"]; ok && v == "" { + delete(existing, "quiet_hours") + } + var buf bytes.Buffer + buf.WriteString("# talk — geschrieben von `talk setup`. Keys gehören NICHT hierher (TALK_*-Umgebungsvariablen).\n\n") + if err := toml.NewEncoder(&buf).Encode(existing); err != nil { + return "", err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return "", err + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, buf.Bytes(), 0o644); err != nil { + return "", err + } + if err := os.Rename(tmp, path); err != nil { + os.Remove(tmp) + return "", err + } + return backup, nil +} + +// mergeInto schreibt src rekursiv in dst (Tabellen zusammenführen). +func mergeInto(dst, src map[string]any) { + for k, v := range src { + if sv, ok := v.(map[string]any); ok { + if dv, ok := dst[k].(map[string]any); ok { + mergeInto(dv, sv) + continue + } + } + dst[k] = v + } +} + +func toAny(m map[string]string) map[string]any { + out := make(map[string]any, len(m)) + for k, v := range m { + out[k] = v + } + return out +} + +// printEffective zeigt die wirksame Konfiguration (Nicht-Terminal-Pfad). +func printEffective(w io.Writer, cfg config.Config, path string) { + fmt.Fprintf(w, " config: %s\n", path) + fmt.Fprintf(w, " audio: %s\n", audioSummary(cfg)) + fmt.Fprintf(w, " provider: %s\n", orDefault(cfg.Provider, "Kette "+strings.Join(provider.DefaultOrder, " → "))) + for _, p := range provider.DefaultOrder { + for _, lang := range []string{"de", "en"} { + if id := cfg.Voice[p][lang]; id != "" { + fmt.Fprintf(w, " voice: %s/%s = %s\n", p, lang, id) + } + } + } + fmt.Fprintf(w, " ruhezeit: %s\n", orDefault(cfg.QuietHours, "keine")) + fmt.Fprintf(w, " autostart: %v\n", cfg.Daemon.Autostart) +} + +func audioSummary(cfg config.Config) string { + if cfg.Audio.Sink == config.SinkNull { + return "null (kein Ton — CI/Server)" + } + return "eingebaute Wiedergabe (oto)" +} + +func orDefault(v, def string) string { + if v == "" { + return def + } + return v +} diff --git a/cmd/talk/setup_test.go b/cmd/talk/setup_test.go new file mode 100644 index 0000000..f0edddf --- /dev/null +++ b/cmd/talk/setup_test.go @@ -0,0 +1,163 @@ +package main + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + + "github.com/BurntSushi/toml" +) + +func readFile(path string) ([]byte, error) { return os.ReadFile(path) } + +// setupEnv stellt den Wizard auf „interaktiv“ und pinnt die Kette auf einen +// Fake-ElevenLabs-Endpunkt (sink null) — Probehören und Testsatz laufen +// gegen den Fake. Rückgabe: Zähler der Synthese-Aufrufe. +func setupEnv(t *testing.T, root string, extra map[string]string) *atomic.Int32 { + t.Helper() + var calls atomic.Int32 + fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + w.Header().Set("Content-Type", "audio/mpeg") + _, _ = w.Write([]byte("mp3")) + })) + t.Cleanup(fake.Close) + kv := map[string]string{ + "XDG_CONFIG_HOME": root, + "TALK_AUDIO_SINK": "null", + "TALK_PROVIDER": "elevenlabs", + "TALK_ELEVENLABS_KEY": "setup-key", + "TALK_ELEVENLABS_ENDPOINT": fake.URL, + } + for k, v := range extra { + kv[k] = v + } + setGetenv(t, kv) + setupInteractive = func(io.Reader) bool { return true } + t.Cleanup(func() { setupInteractive = isCharDevice }) + return &calls +} + +// TestSetupSchreibtConfig: Antworten über stdin — Stimme 2 für Deutsch +// (probehören, übernehmen), Englisch Default behalten, Ruhezeit setzen — +// ergeben eine config.toml mit genau diesen Werten; Probehören und +// Testsatz treffen den Fake. +func TestSetupSchreibtConfig(t *testing.T) { + root := t.TempDir() + calls := setupEnv(t, root, nil) + stdin := strings.NewReader("2\nj\nj\n\nn\nj\n22:00-07:00\n") + + var out, errBuf bytes.Buffer + code := run([]string{"setup"}, stdin, &out, &errBuf) + if code != exitOK { + t.Fatalf("talk setup: Exit %d\nstdout: %s\nstderr: %s", code, out.String(), errBuf.String()) + } + path := filepath.Join(root, "talk", "config.toml") + var got struct { + QuietHours string `toml:"quiet_hours"` + Voice map[string]map[string]string `toml:"voice"` + } + if _, err := toml.DecodeFile(path, &got); err != nil { + t.Fatalf("config lesen: %v", err) + } + if got.QuietHours != "22:00-07:00" { + t.Errorf("quiet_hours = %q", got.QuietHours) + } + if got.Voice["elevenlabs"]["de"] != "pNInz6obpgDQGcFmaJgB" { // Nr. 2 = Adam + t.Errorf("voice.elevenlabs.de = %q, want Adam", got.Voice["elevenlabs"]["de"]) + } + if _, ok := got.Voice["elevenlabs"]["en"]; ok { + t.Errorf("voice.elevenlabs.en gesetzt, obwohl Default behalten wurde: %v", got.Voice) + } + if n := calls.Load(); n != 2 { // 1× Probehören de + 1× Testsatz + t.Errorf("Fake-Aufrufe = %d, want 2 (Probehören + Testsatz)", n) + } + if _, err := os.Stat(path + ".bak"); !os.IsNotExist(err) { + t.Errorf("Sicherung ohne Vorgänger-Datei angelegt") + } + if !strings.Contains(out.String(), "Gespeichert") || !strings.Contains(out.String(), "Gesprochen über elevenlabs") { + t.Errorf("stdout = %q", out.String()) + } +} + +// TestSetupMergtBestehendeConfig: Vorhandene Werte (anderer Port, andere +// Provider-Stimme) bleiben erhalten, es gibt eine .bak, und „keine“ löscht +// eine vorhandene Ruhezeit. +func TestSetupMergtBestehendeConfig(t *testing.T) { + root := t.TempDir() + setupEnv(t, root, nil) + dir := filepath.Join(root, "talk") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "config.toml") + orig := "quiet_hours = \"18:00-08:00\"\n\n[server]\nport = 7900\n\n[voice.edge]\nde = \"de-DE-ConradNeural\"\n" + if err := os.WriteFile(path, []byte(orig), 0o644); err != nil { + t.Fatal(err) + } + // Das Setup selbst muss auf den Default-Port 7800 zeigen, nicht 7900 aus + // der Datei — sonst würde die Daemon-Erkennung einen fremden Port + // anfassen. TALK_PORT=1 aus setGetenv schlägt die Datei. + stdin := strings.NewReader("1\nn\nj\n1\nn\nj\nkeine\n") + var out, errBuf bytes.Buffer + if code := run([]string{"setup"}, stdin, &out, &errBuf); code != exitOK { + t.Fatalf("talk setup: Exit %d\n%s\n%s", code, out.String(), errBuf.String()) + } + bak, err := os.ReadFile(path + ".bak") + if err != nil || string(bak) != orig { + t.Errorf(".bak = %q (%v), want Original", bak, err) + } + var got map[string]any + if _, err := toml.DecodeFile(path, &got); err != nil { + t.Fatal(err) + } + if _, ok := got["quiet_hours"]; ok { + t.Errorf("quiet_hours nicht entfernt: %v", got) + } + if srv, _ := got["server"].(map[string]any); srv["port"] != int64(7900) { + t.Errorf("server.port verloren: %v", got["server"]) + } + voice, _ := got["voice"].(map[string]any) + if edge, _ := voice["edge"].(map[string]any); edge["de"] != "de-DE-ConradNeural" { + t.Errorf("voice.edge.de verloren: %v", voice) + } + // Default behalten (Nr. 1 = Rachel = Repo-Default) → kein elevenlabs-Eintrag. + if _, ok := voice["elevenlabs"]; ok { + t.Errorf("voice.elevenlabs geschrieben, obwohl Default behalten: %v", voice) + } +} + +// TestSetupAbbruchSchreibtNichts: „q“ bricht ab, keine Datei entsteht. +func TestSetupAbbruchSchreibtNichts(t *testing.T) { + root := t.TempDir() + setupEnv(t, root, nil) + var out, errBuf bytes.Buffer + if code := run([]string{"setup"}, strings.NewReader("q\n"), &out, &errBuf); code != exitOK { + t.Fatalf("Exit %d, stderr %q", code, errBuf.String()) + } + if !strings.Contains(out.String(), "Abgebrochen") { + t.Errorf("stdout = %q", out.String()) + } + if _, err := os.Stat(filepath.Join(root, "talk", "config.toml")); !os.IsNotExist(err) { + t.Error("config.toml trotz Abbruch angelegt") + } +} + +// TestSetupOhneTerminal: Pipe → keine Fragen, wirksame Konfiguration, Exit 0. +func TestSetupOhneTerminal(t *testing.T) { + root := t.TempDir() + setGetenv(t, map[string]string{"XDG_CONFIG_HOME": root, "TALK_AUDIO_SINK": "null"}) + var out, errBuf bytes.Buffer + if code := runCLI([]string{"setup"}, &out, &errBuf); code != exitOK { + t.Fatalf("Exit %d, stderr %q", code, errBuf.String()) + } + if !strings.Contains(out.String(), "Wirksame Konfiguration") || !strings.Contains(out.String(), "ruhezeit: keine") { + t.Errorf("stdout = %q", out.String()) + } +} diff --git a/cmd/talk/testcmd.go b/cmd/talk/testcmd.go new file mode 100644 index 0000000..6b15313 --- /dev/null +++ b/cmd/talk/testcmd.go @@ -0,0 +1,77 @@ +package main + +import ( + "context" + "fmt" + "io" + "path/filepath" + "time" + + "code.m3ta.dev/m3tam3re/talk/internal/config" + "code.m3ta.dev/m3tam3re/talk/internal/queue" + "code.m3ta.dev/m3tam3re/talk/internal/speak" +) + +// testPhrase ist die feste Testphrase je Sprache. +var testPhrase = map[string]string{ + "de": "Dies ist eine Testansage von talk. Wenn du das hörst, funktioniert alles.", + "en": "This is a test announcement from talk. If you can hear this, everything works.", +} + +// runTest implementiert `talk test`: spricht die Testphrase DIREKT in diesem +// Prozess (nicht über den Daemon) — so sieht der Befehl, welche Stufe +// wirklich gesprochen hat und wie lange Synthese und Wiedergabe dauerten. +// Mute und Ruhezeit gelten hier bewusst nicht (der Nutzer will gerade +// testen); ein aktives Mute wird aber genannt. +func runTest(args []string, opts *sayOptions, _ io.Reader, stdout, stderr io.Writer) int { + if len(args) > 0 { + fmt.Fprintln(stderr, "talk test: keine Argumente erwartet (Sprache: --lang de|en)") + return exitUsage + } + if code, bad := validateSayOptions(opts, stderr); bad { + return code + } + lang := opts.lang + if lang == "" { + lang = "de" + } + cfg, err := config.Load(config.Options{Getenv: getenv}) + if err != nil { + fmt.Fprintf(stderr, "talk: %v\n", err) + return exitFailure + } + if dir, err := config.Dir(getenv); err == nil { + if (queue.MuteStore{Path: filepath.Join(dir, queue.MuteFileName)}).Muted() { + fmt.Fprintln(stderr, "talk test: Mute ist an — der Test spielt trotzdem; normale Ansagen bleiben stumm (talk unmute)") + } + } + if cfg.Audio.Sink == config.SinkNull { + fmt.Fprintln(stderr, "talk test: audio.sink = null — es wird kein Ton ausgegeben") + } + + engine, err := speak.New(cfg, getenv, stderr) + if err != nil { + fmt.Fprintf(stderr, "talk: %v\n", err) + return exitFailure + } + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + out, err := engine.Speak(ctx, speak.Request{Text: testPhrase[lang], Lang: lang, Provider: opts.provider, Voice: opts.voice}) + if err != nil { + fmt.Fprintf(stderr, "talk test: %v\n", err) + fmt.Fprintln(stderr, "Hinweis: `talk doctor` zeigt, welche Stufe warum fehlt.") + return exitFailure + } + fmt.Fprintf(stdout, "provider: %s\n", out.Provider) + fmt.Fprintf(stdout, "sprache: %s\n", lang) + switch { + case out.Discarded: + fmt.Fprintln(stdout, "wiedergabe: verworfen (audio.sink = null)") + case out.Direct: + fmt.Fprintf(stdout, "dauer: %s (Direktansage über das OS)\n", out.Synthesis.Round(time.Millisecond)) + default: + fmt.Fprintf(stdout, "synthese: %s (%d Bytes)\n", out.Synthesis.Round(time.Millisecond), out.Bytes) + fmt.Fprintf(stdout, "wiedergabe: %s\n", out.Playback.Round(time.Millisecond)) + } + return exitOK +} diff --git a/cmd/talk/testcmd_test.go b/cmd/talk/testcmd_test.go new file mode 100644 index 0000000..211d3a3 --- /dev/null +++ b/cmd/talk/testcmd_test.go @@ -0,0 +1,37 @@ +package main + +import ( + "bytes" + "strings" + "testing" +) + +// TestTestBefehl: `talk test` spricht direkt über die Kette (hier: fake +// espeak → osbuiltin) und nennt den Provider; sink null wird angesagt. +func TestTestBefehl(t *testing.T) { + seen := withFakeEspeak(t) + setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null", "TALK_PROVIDER": "osbuiltin"}) + var out, errBuf bytes.Buffer + code := runCLI([]string{"test", "--lang", "en"}, &out, &errBuf) + if code != exitOK { + t.Fatalf("talk test: Exit %d, stderr %q", code, errBuf.String()) + } + if !strings.Contains(out.String(), "provider: osbuiltin") || !strings.Contains(out.String(), "sprache: en") { + t.Errorf("stdout = %q", out.String()) + } + if !strings.Contains(errBuf.String(), "kein Ton") { + t.Errorf("stderr = %q, will Hinweis auf sink null", errBuf.String()) + } + if _, err := readFile(seen); err != nil { + t.Errorf("fake espeak nicht gerufen: %v", err) + } +} + +// TestTestBefehlUngueltigeSprache: --lang prüft wie say. +func TestTestBefehlUngueltigeSprache(t *testing.T) { + setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"}) + var out, errBuf bytes.Buffer + if code := runCLI([]string{"test", "--lang", "fr"}, &out, &errBuf); code == exitOK || !strings.Contains(errBuf.String(), "--lang") { + t.Errorf("Exit %d, stderr %q", code, errBuf.String()) + } +} diff --git a/cmd/talk/voices.go b/cmd/talk/voices.go new file mode 100644 index 0000000..d4193d8 --- /dev/null +++ b/cmd/talk/voices.go @@ -0,0 +1,104 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "slices" + "text/tabwriter" + + "code.m3ta.dev/m3tam3re/talk/internal/config" + "code.m3ta.dev/m3tam3re/talk/internal/provider" +) + +// voiceRow ist eine Zeile von `talk voices` (auch die JSON-Form). +type voiceRow struct { + Provider string `json:"provider"` + Lang string `json:"lang"` + ID string `json:"id"` + Name string `json:"name"` + Default bool `json:"default"` +} + +// runVoices implementiert `talk voices`: Katalog der bekannten Stimmen plus +// die in der config.toml gesetzten (auch wenn sie nicht im Katalog stehen), +// gefiltert nach --provider/--lang; „*“ markiert die effektive Default- +// Stimme je Provider und Sprache. osbuiltin erscheint als eine Zeile ohne +// ID — das Betriebssystem wählt die Stimme. +func runVoices(args []string, opts *sayOptions, _ io.Reader, stdout, stderr io.Writer) int { + if len(args) > 0 { + fmt.Fprintln(stderr, "talk voices: keine Argumente erwartet (Filter: --provider, --lang)") + return exitUsage + } + if code, bad := validateSayOptions(opts, stderr); bad { + return code + } + if opts.provider != "" && !slices.Contains(provider.Names(), opts.provider) { + fmt.Fprintf(stderr, "talk voices: unbekannter Provider %q (verfügbar: %v)\n", opts.provider, provider.Names()) + return exitFailure + } + cfg, err := config.Load(config.Options{Getenv: getenv}) + if err != nil { + fmt.Fprintf(stderr, "talk: %v\n", err) + return exitFailure + } + + rows := voiceRows(cfg, opts.provider, opts.lang) + if opts.jsonOut { + enc := json.NewEncoder(stdout) + enc.SetIndent("", " ") + _ = enc.Encode(rows) + return exitOK + } + tw := tabwriter.NewWriter(stdout, 0, 4, 2, ' ', 0) + fmt.Fprintln(tw, "PROVIDER\tLANG\tVOICE-ID\tNAME\tDEFAULT") + for _, r := range rows { + mark := "" + if r.Default { + mark = "*" + } + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", r.Provider, r.Lang, r.ID, r.Name, mark) + } + tw.Flush() + fmt.Fprintln(stderr, "Stimme setzen: --voice einmalig, oder dauerhaft in der config.toml unter [voice.] de = \"\".") + return exitOK +} + +// voiceRows baut die Tabelle: Katalog + Config-Einträge, gefiltert. +func voiceRows(cfg config.Config, onlyProvider, onlyLang string) []voiceRow { + var rows []voiceRow + seen := map[[3]string]bool{} + add := func(prov, lang, id, name string) { + if (onlyProvider != "" && prov != onlyProvider) || (onlyLang != "" && lang != onlyLang) { + return + } + key := [3]string{prov, lang, id} + if seen[key] { + return + } + seen[key] = true + rows = append(rows, voiceRow{ + Provider: prov, Lang: lang, ID: id, Name: name, + Default: id != "" && provider.DefaultVoice(prov, lang, cfg.Voice) == id, + }) + } + for _, name := range provider.DefaultOrder { + for _, lang := range []string{"de", "en"} { + // Config-Eintrag zuerst, damit er oben steht (auch ohne Katalog). + if id := cfg.Voice[name][lang]; id != "" { + label := provider.VoiceName(name, id) + if label == "" { + label = "(aus config.toml)" + } + add(name, lang, id, label) + } + } + } + for _, v := range provider.Catalog() { + add(v.Provider, v.Lang, v.ID, v.Name) + } + for _, lang := range []string{"de", "en"} { + add("osbuiltin", lang, "", "Stimme des Betriebssystems (wählt das OS)") + } + return rows +} diff --git a/cmd/talk/voices_test.go b/cmd/talk/voices_test.go new file mode 100644 index 0000000..9ea0f0f --- /dev/null +++ b/cmd/talk/voices_test.go @@ -0,0 +1,93 @@ +package main + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "code.m3ta.dev/m3tam3re/talk/internal/config" +) + +// TestVoiceRowsDefaultUndConfig: Ohne Config markiert „*“ den Repo-Default; +// ein Config-Mapping verschiebt die Markierung und steht als eigene Zeile +// oben — auch für IDs außerhalb des Katalogs. Filter greifen. +func TestVoiceRowsDefaultUndConfig(t *testing.T) { + cfg := config.Defaults() + rows := voiceRows(cfg, "edge", "de") + if len(rows) == 0 || rows[0].ID != "de-DE-KatjaNeural" || !rows[0].Default { + t.Fatalf("edge/de ohne Config: erste Zeile = %+v, want Katja als Default", rows) + } + for _, r := range rows { + if r.Provider != "edge" || r.Lang != "de" { + t.Errorf("Filter verletzt: %+v", r) + } + } + + cfg.Voice = map[string]map[string]string{"edge": {"de": "de-DE-EigeneNeural"}} + rows = voiceRows(cfg, "edge", "de") + if rows[0].ID != "de-DE-EigeneNeural" || !rows[0].Default || rows[0].Name != "(aus config.toml)" { + t.Errorf("Config-ID außerhalb des Katalogs: erste Zeile = %+v", rows[0]) + } + defaults := 0 + for _, r := range rows { + if r.Default { + defaults++ + } + if r.ID == "de-DE-KatjaNeural" && r.Default { + t.Error("Katja weiter als Default markiert, obwohl Config eine andere Stimme setzt") + } + } + if defaults != 1 { + t.Errorf("%d Default-Markierungen, want genau 1", defaults) + } + + // osbuiltin erscheint ohne ID, nie als Default. + all := voiceRows(cfg, "", "") + found := false + for _, r := range all { + if r.Provider == "osbuiltin" { + found = true + if r.ID != "" || r.Default { + t.Errorf("osbuiltin-Zeile = %+v, want leere ID ohne Default", r) + } + } + } + if !found { + t.Error("osbuiltin fehlt in der Gesamtliste") + } +} + +// TestVoicesCLI: Tabelle mit Kopfzeile, --json als Liste, unbekannter +// Provider ist ein Fehler. +func TestVoicesCLI(t *testing.T) { + setGetenv(t, nil) + var out, errBuf bytes.Buffer + if code := runCLI([]string{"voices", "--lang", "en"}, &out, &errBuf); code != exitOK { + t.Fatalf("talk voices: Exit %d, stderr %q", code, errBuf.String()) + } + if !strings.HasPrefix(out.String(), "PROVIDER") || !strings.Contains(out.String(), "en-US-AriaNeural") { + t.Errorf("Tabelle = %q", out.String()) + } + + out.Reset() + errBuf.Reset() + if code := runCLI([]string{"voices", "--json", "--provider", "openai"}, &out, &errBuf); code != exitOK { + t.Fatalf("talk voices --json: Exit %d, stderr %q", code, errBuf.String()) + } + var rows []voiceRow + if err := json.Unmarshal(out.Bytes(), &rows); err != nil || len(rows) == 0 { + t.Fatalf("JSON = %q (%v)", out.String(), err) + } + for _, r := range rows { + if r.Provider != "openai" { + t.Errorf("--provider openai liefert %+v", r) + } + } + + out.Reset() + errBuf.Reset() + if code := runCLI([]string{"voices", "--provider", "siri"}, &out, &errBuf); code == exitOK || !strings.Contains(errBuf.String(), "unbekannter Provider") { + t.Errorf("unbekannter Provider: Exit %d, stderr %q", code, errBuf.String()) + } +} diff --git a/internal/audio/check.go b/internal/audio/check.go new file mode 100644 index 0000000..9a3ee84 --- /dev/null +++ b/internal/audio/check.go @@ -0,0 +1,37 @@ +package audio + +import ( + "context" + "fmt" + "time" +) + +// Check prüft, ob der Sink name einsatzbereit ist, ohne Ton zu erzeugen: +// null ist immer bereit; oto initialisiert den Audio-Kontext (Standard- +// Format 44,1 kHz Stereo 16 Bit) und wartet auf dessen Bereitschaft — so +// fällt „kein Audiogerät" (Server, Container, fehlende PulseAudio/ALSA- +// Bibliothek) in `talk doctor` auf, nicht erst bei der ersten Ansage. +func Check(ctx context.Context, name string) error { + switch name { + case NameNull: + return nil + case NameOto: + c, err := contextFor(pcmFormat{SampleRate: 44100, Channels: 2, BitsPerCoded: 16}) + if err != nil { + return err + } + select { + case <-c.ready: + if err := c.ctx.Err(); err != nil { + return fmt.Errorf("oto: Initialisierung fehlgeschlagen: %w", err) + } + return nil + case <-ctx.Done(): + return fmt.Errorf("oto: Audio-Gerät antwortet nicht (%v)", ctx.Err()) + case <-time.After(5 * time.Second): + return fmt.Errorf("oto: Audio-Gerät antwortet nicht binnen 5 s") + } + default: + return fmt.Errorf("unbekannter audio sink %q (erlaubt: %q, %q)", name, NameOto, NameNull) + } +} diff --git a/internal/config/path.go b/internal/config/path.go index d1919ba..b6d9aaf 100644 --- a/internal/config/path.go +++ b/internal/config/path.go @@ -54,3 +54,13 @@ func dirFor(goos string, getenv func(string) string) (string, error) { return filepath.Join(home, ".config", "talk"), nil } } + +// Path liefert den vollständigen Pfad der config.toml im platform-nativen +// Config-Verzeichnis (Diagnose, Setup-Wizard). +func Path(getenv func(string) string) (string, error) { + dir, err := Dir(getenv) + if err != nil { + return "", err + } + return filepath.Join(dir, fileName), nil +} diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index a2bdd25..f718d4c 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -90,12 +90,17 @@ func Run(ctx context.Context, opts Options) error { MaxText: cfg.Queue.MaxText, MaxAge: cfg.Queue.MaxAge, }, func(ctx context.Context, item queue.Item) error { - return engine.Speak(ctx, speak.Request{ + out, err := engine.Speak(ctx, speak.Request{ Text: item.Text, Lang: item.Lang, Provider: item.Provider, Voice: item.Voice, }) + if err == nil && !out.Discarded { + logger.Printf("ansage %s über %s (synthese %s, wiedergabe %s)", item.ID, out.Provider, + out.Synthesis.Round(time.Millisecond), out.Playback.Round(time.Millisecond)) + } + return err }, lw) // Persistenter Mute-State und PID-Datei im Config-Dir. Ohne Config-Dir diff --git a/internal/provider/catalog.go b/internal/provider/catalog.go new file mode 100644 index 0000000..5fa36b5 --- /dev/null +++ b/internal/provider/catalog.go @@ -0,0 +1,90 @@ +package provider + +import "sort" + +// Voice ist ein Eintrag des Stimmen-Katalogs: eine bekannte Stimme eines +// Providers für eine Sprache, mit lesbarem Namen. Der Katalog ist kuratiert +// (die gängigen Standardstimmen), nicht vollständig — jede Voice-ID des +// Providers funktioniert auch ohne Katalog-Eintrag (--voice, config). +type Voice struct { + Provider string + Lang string // de | en + ID string + Name string +} + +// catalog sind die bekannten Stimmen je Provider. ElevenLabs: öffentliche +// Premade-Stimmen (multilingual, daher für de UND en gelistet). OpenAI: die +// Stimmen der TTS-API (sprachunabhängig). Edge: Neural-Stimmen der +// jeweiligen Locale. osbuiltin kennt keine IDs (das OS wählt). +var catalog = []Voice{ + {"elevenlabs", "de", "21m00Tcm4TlvDq8ikWAM", "Rachel"}, + {"elevenlabs", "de", "pNInz6obpgDQGcFmaJgB", "Adam"}, + {"elevenlabs", "de", "EXAVITQu4vr4xnSDxMaL", "Bella"}, + {"elevenlabs", "de", "ErXwobaYiN019PkySvjV", "Antoni"}, + {"elevenlabs", "de", "TxGEqnHWrfWFTfGW9XjX", "Josh"}, + {"elevenlabs", "en", "21m00Tcm4TlvDq8ikWAM", "Rachel"}, + {"elevenlabs", "en", "pNInz6obpgDQGcFmaJgB", "Adam"}, + {"elevenlabs", "en", "EXAVITQu4vr4xnSDxMaL", "Bella"}, + {"elevenlabs", "en", "ErXwobaYiN019PkySvjV", "Antoni"}, + {"elevenlabs", "en", "TxGEqnHWrfWFTfGW9XjX", "Josh"}, + {"openai", "de", "alloy", "Alloy"}, + {"openai", "de", "echo", "Echo"}, + {"openai", "de", "fable", "Fable"}, + {"openai", "de", "onyx", "Onyx"}, + {"openai", "de", "nova", "Nova"}, + {"openai", "de", "shimmer", "Shimmer"}, + {"openai", "en", "alloy", "Alloy"}, + {"openai", "en", "echo", "Echo"}, + {"openai", "en", "fable", "Fable"}, + {"openai", "en", "onyx", "Onyx"}, + {"openai", "en", "nova", "Nova"}, + {"openai", "en", "shimmer", "Shimmer"}, + {"edge", "de", "de-DE-KatjaNeural", "Katja"}, + {"edge", "de", "de-DE-ConradNeural", "Conrad"}, + {"edge", "de", "de-DE-AmalaNeural", "Amala"}, + {"edge", "de", "de-DE-KillianNeural", "Killian"}, + {"edge", "de", "de-AT-IngridNeural", "Ingrid (AT)"}, + {"edge", "de", "de-CH-LeniNeural", "Leni (CH)"}, + {"edge", "en", "en-US-AriaNeural", "Aria"}, + {"edge", "en", "en-US-GuyNeural", "Guy"}, + {"edge", "en", "en-US-JennyNeural", "Jenny"}, + {"edge", "en", "en-US-ChristopherNeural", "Christopher"}, + {"edge", "en", "en-GB-SoniaNeural", "Sonia (GB)"}, + {"edge", "en", "en-GB-RyanNeural", "Ryan (GB)"}, +} + +// Catalog liefert den Stimmen-Katalog in stabiler Reihenfolge +// (Ketten-Reihenfolge der Provider, dann de vor en, dann Katalog-Reihe). +func Catalog() []Voice { + out := make([]Voice, len(catalog)) + copy(out, catalog) + rank := map[string]int{} + for i, name := range DefaultOrder { + rank[name] = i + } + sort.SliceStable(out, func(i, j int) bool { + if rank[out[i].Provider] != rank[out[j].Provider] { + return rank[out[i].Provider] < rank[out[j].Provider] + } + return out[i].Lang < out[j].Lang + }) + return out +} + +// VoiceName liefert den Katalog-Namen einer Voice-ID; leer, wenn unbekannt. +func VoiceName(provider, id string) string { + for _, v := range catalog { + if v.Provider == provider && v.ID == id { + return v.Name + } + } + return "" +} + +// DefaultVoice liefert die effektive Default-Stimme eines Providers für +// eine Sprache: config-Mapping voice.. vor dem Repo-Default +// (ohne --voice-Pin). Leer bei osbuiltin (das OS wählt). +func DefaultVoice(provider, lang string, cfgVoices map[string]map[string]string) string { + return resolveVoice(provider, lang, "", cfgVoices[provider]) +} diff --git a/internal/provider/chain.go b/internal/provider/chain.go index 067a00d..1653a4c 100644 --- a/internal/provider/chain.go +++ b/internal/provider/chain.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "strings" + "time" ) // DefaultOrder ist die bindende Default-Fallback-Reihenfolge der Kette @@ -235,24 +236,50 @@ func (c *Chain) SpeakDirect(ctx context.Context, text, lang string) error { // statt sie wegzuwerfen und neu zu laufen. Nur ein Fehler aller Stufen // oder eine fehlgeschlagene Wiedergabe sind Fehler. func (c *Chain) Speak(ctx context.Context, text, lang string) error { + _, err := c.SpeakOutcome(ctx, text, lang) + return err +} + +// Outcome beschreibt eine gelungene Ansage: welche Stufe gesprochen hat, +// wie lange Synthese (Ketten-Durchlauf bis zu den Bytes) und Wiedergabe +// gedauert haben, und ob es eine Direktansage war (keine Bytes, Provider +// spielt selbst — dann ist Synthesis die Gesamtzeit und Playback 0). +// Discarded meldet die tonlose Verwerfung einer Direktansage am null-Sink. +type Outcome struct { + Provider string + Direct bool + Discarded bool + Bytes int + Synthesis time.Duration + Playback time.Duration +} + +// SpeakOutcome ist Speak mit Bericht (Diagnose: `talk test`, Daemon-Log). +func (c *Chain) SpeakOutcome(ctx context.Context, text, lang string) (Outcome, error) { + start := time.Now() r := c.walk(ctx, text, lang) + synth := time.Since(start) switch { case r.data != nil: if c.cfg.Player == nil { - return fmt.Errorf("kette: kein Player konfiguriert — Bytes können nicht abgespielt werden") + return Outcome{}, fmt.Errorf("kette: kein Player konfiguriert — Bytes können nicht abgespielt werden") } + playStart := time.Now() if err := c.cfg.Player.Play(ctx, r.data); err != nil { - return fmt.Errorf("kette: Wiedergabe über %s fehlgeschlagen: %w", c.cfg.Player.Name(), err) + return Outcome{}, fmt.Errorf("kette: Wiedergabe über %s fehlgeschlagen: %w", c.cfg.Player.Name(), err) } - return nil + return Outcome{Provider: r.used.Name(), Bytes: len(r.data), Synthesis: synth, Playback: time.Since(playStart)}, nil case r.direct != nil: if c.cfg.Player != nil && c.cfg.Player.Name() == NullPlayerName { chainLogf(c.cfg, "kette: Direktansage verworfen (audio.sink=null)\n") - return nil + return Outcome{Provider: r.directName, Direct: true, Discarded: true, Synthesis: synth}, nil } - return r.direct.SpeakDirect(ctx, text, lang) + if err := r.direct.SpeakDirect(ctx, text, lang); err != nil { + return Outcome{}, err + } + return Outcome{Provider: r.directName, Direct: true, Synthesis: time.Since(start)}, nil default: - return r.noTierErr() + return Outcome{}, r.noTierErr() } } diff --git a/internal/provider/osbuiltin.go b/internal/provider/osbuiltin.go index 3d904f2..d939242 100644 --- a/internal/provider/osbuiltin.go +++ b/internal/provider/osbuiltin.go @@ -60,6 +60,35 @@ func (OSBuiltin) Available(ctx context.Context) bool { } } +// Describe nennt den gefundenen Plattform-Mechanismus für die Diagnose +// (`talk doctor`): z. B. „espeak-ng (Audio-Bytes)", „spd-say (nur +// Direktansage; espeak-ng für Audio-Bytes fehlt)", „say", „powershell +// (SAPI)" — oder, was fehlt. +func (OSBuiltin) Describe() string { + switch runtime.GOOS { + case "darwin": + if _, err := exec.LookPath("say"); err == nil { + return "say (macOS)" + } + return "say nicht gefunden" + case "windows": + if _, err := exec.LookPath("powershell"); err == nil { + return "powershell (SAPI System.Speech)" + } + return "powershell nicht gefunden" + default: + for _, bin := range []string{"espeak-ng", "espeak"} { + if _, err := exec.LookPath(bin); err == nil { + return bin + " (Audio-Bytes)" + } + } + if _, err := exec.LookPath("spd-say"); err == nil { + return "spd-say (nur Direktansage; espeak-ng für Audio-Bytes fehlt)" + } + return "weder espeak-ng/espeak noch spd-say gefunden (espeak-ng installieren)" + } +} + // Synthesize implementiert Provider. Rückgabeformat: RIFF/WAVE (PCM) — // abspielbar über jeden Sink des audio-Pakets. func (p OSBuiltin) Synthesize(ctx context.Context, text, lang string) ([]byte, error) { diff --git a/internal/speak/engine.go b/internal/speak/engine.go index eb32ce6..a492cba 100644 --- a/internal/speak/engine.go +++ b/internal/speak/engine.go @@ -64,20 +64,28 @@ func New(cfg config.Config, getenv func(string) string, log io.Writer) (*Engine, // 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 { +// 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) } - 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 provider.Outcome{}, err } - return chain.Speak(ctx, req.Text, lang) + 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. diff --git a/test/doctor_test.go b/test/doctor_test.go new file mode 100644 index 0000000..90420da --- /dev/null +++ b/test/doctor_test.go @@ -0,0 +1,40 @@ +package test + +// Prozessgrenzen-Tests für die Diagnose-Befehle gegen einen echten Daemon. + +import ( + "strings" + "testing" +) + +// TestDoctorMitDaemon: Läuft der Daemon, ist er ✔ mit Version und Log- +// Pfad; voices und test funktionieren als Prozess. +func TestDoctorMitDaemon(t *testing.T) { + root := t.TempDir() + fake := newFakeProvider(t, 0) + d := startDaemon(t, root, fake.srv.URL, nil) + env := daemonEnv(root, d.port, fake.srv.URL, nil) + + res := runTalk(t, env, "doctor") + if res.code != 0 { + t.Fatalf("talk doctor: Exit %d\n%s\n%s", res.code, res.stdout, res.stderr) + } + for _, want := range []string{"✔ Daemon", "läuft unter " + d.baseURL, "daemon.log", "✔ elevenlabs", "Key gesetzt"} { + if !strings.Contains(res.stdout, want) { + t.Errorf("doctor ohne %q:\n%s", want, res.stdout) + } + } + + res = runTalk(t, env, "doctor", "--probe") + if res.code != 0 || !strings.Contains(res.stdout, "Probe ok") { + t.Errorf("doctor --probe: Exit %d\n%s", res.code, res.stdout) + } + fake.waitArrived(t, 1) + + if res := runTalk(t, env, "voices"); res.code != 0 || !strings.HasPrefix(res.stdout, "PROVIDER") { + t.Errorf("talk voices: Exit %d, stdout %q", res.code, res.stdout) + } + if res := runTalk(t, env, "test"); res.code != 0 || !strings.Contains(res.stdout, "provider: elevenlabs") { + t.Errorf("talk test: Exit %d, stdout %q, stderr %q", res.code, res.stdout, res.stderr) + } +}