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
+1632 -56

No files matched your search

+48 -9
View File
@@ -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.<provider>.<lang>`
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
+38 -5
View File
@@ -83,22 +83,29 @@ var commands = []command{
{
name: "voices",
short: "Verfügbare Stimmen anzeigen",
detail: "Listet die Stimmen aller Provider (Defaults + Config-Overrides) als Tabelle.",
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.",
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 ✔/✘/⚠.",
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.",
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())
}
}
+37
View File
@@ -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)
}
}
+10
View File
@@ -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
}
+6 -1
View File
@@ -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
+90
View File
@@ -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.<provider>.<lang> 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])
}
+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()
}
}
+29
View File
@@ -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) {
+13 -5
View File
@@ -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.
+40
View File
@@ -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)
}
}