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

+45 -12
View File
@@ -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.<provider>.<lang> 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-
-29
View File
@@ -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)
+311
View File
@@ -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()
}
+116
View File
@@ -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())
}
}
}
+7 -1
View File
@@ -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,
+380
View File
@@ -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.<provider>) 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
}
+163
View File
@@ -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())
}
}
+77
View File
@@ -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
}
+37
View File
@@ -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())
}
}
+104
View File
@@ -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 <id> einmalig, oder dauerhaft in der config.toml unter [voice.<provider>] de = \"<id>\".")
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
}
+93
View File
@@ -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())
}
}