feat(cli): doctor, test, voices, setup — Einrichtung und Diagnose ohne TUI

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>
This commit is contained in:
m3ta-chironandClaude Fable 5.1 committed 2026-10-24 10:08:44 +02:00
1 parent 92333ff1c3
commit df9a9b30cf
20 files changed
+1639 -63

No files matched your search

+33 -6
View File
@@ -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()
}
}