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:
1 parent
92333ff1c3
commit
df9a9b30cf
20 files changed
+1632
-56
No files matched your search
@@ -21,19 +21,58 @@ Runtime-Abhängigkeiten.
|
|||||||
| `talk status` | Daemon-Status (Queue, Mute, Provider, Version) |
|
| `talk status` | Daemon-Status (Queue, Mute, Provider, Version) |
|
||||||
| `talk cancel` | laufende und geplante Ansagen abbrechen |
|
| `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 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 voices` | bekannte Stimmen je Provider/Sprache, Default markiert (`--provider`, `--lang`, `--json`) |
|
||||||
| `talk test` | Test-Ansage ausgeben |
|
| `talk test` | Testsatz sprechen; zeigt Provider und Zeiten (`--lang`, `--provider`, `--voice`) |
|
||||||
| `talk doctor` | Selbstdiagnose des Setups |
|
| `talk doctor` | Selbstdiagnose: Config, Daemon, Audio, Provider, Mute, Gesetze (`--probe`, `--json`) |
|
||||||
| `talk setup` | geführte Ersteinrichtung (Wizard) |
|
| `talk setup` | Ersteinrichtung in wenigen Fragen: Stimmen probehören, Ruhezeit, config.toml |
|
||||||
|
|
||||||
Say-Flags (gelten auch für `talk "Text"`): `--wait`, `--now`, `--priority`,
|
Say-Flags (gelten auch für `talk "Text"`): `--wait`, `--now`, `--priority`,
|
||||||
`--voice`, `--lang`, `--provider`.
|
`--voice`, `--lang`, `--provider`.
|
||||||
|
|
||||||
**Hinweis:** In diesem Stand sind implementiert: `say` (und `talk "Text"`)
|
## Erste Schritte
|
||||||
mit der kompletten Provider-Kette und Daemon-Anbindung (non-blocking,
|
|
||||||
Autostart, siehe unten), `mute`, `unmute`, `status`, `cancel`, `daemon`
|
```console
|
||||||
sowie `wrap`. Die übrigen Befehle sind Stubs und melden `not implemented
|
talk doctor # Was ist da, was fehlt? Eine Zeile pro Punkt.
|
||||||
yet` (Exit-Code ≠ 0).
|
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
|
## Daemon
|
||||||
|
|
||||||
|
|||||||
+38
-5
@@ -83,22 +83,29 @@ var commands = []command{
|
|||||||
{
|
{
|
||||||
name: "voices",
|
name: "voices",
|
||||||
short: "Verfügbare Stimmen anzeigen",
|
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",
|
name: "test",
|
||||||
short: "Test-Ansage ausgeben",
|
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",
|
name: "doctor",
|
||||||
short: "Setup-Diagnose anzeigen",
|
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",
|
name: "setup",
|
||||||
short: "Geführte Ersteinrichtung (Wizard)",
|
short: "Geführte Ersteinrichtung",
|
||||||
detail: "Interaktiver Wizard: Provider-Keys, Stimmen probehören, config.toml schreiben. Ohne TTY: plain.",
|
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
|
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
|
// run ist der Einstiegspunkt der CLI (Argumente ohne Programmnamen). Alle
|
||||||
// Ausgaben gehen auf die übergebenen Writer, stdin ist als Reader injiziert
|
// Ausgaben gehen auf die übergebenen Writer, stdin ist als Reader injiziert
|
||||||
// — damit ist run direkt und ohne Prozessstart testbar (auch der stdin-
|
// — damit ist run direkt und ohne Prozessstart testbar (auch der stdin-
|
||||||
|
|||||||
@@ -14,14 +14,6 @@ var wantSubcommands = []string{
|
|||||||
"say", "wrap", "mute", "unmute", "status", "cancel", "voices", "test", "doctor", "setup",
|
"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
|
// runCLI ist der Test-Einstieg in run mit leerem stdin (kein Text über
|
||||||
// Pipe) — dem üblichen Aufruf ohne Eingabe.
|
// Pipe) — dem üblichen Aufruf ohne Eingabe.
|
||||||
func runCLI(argv []string, stdout, stderr io.Writer) int {
|
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) {
|
func TestSayHelpZeigtFlags(t *testing.T) {
|
||||||
var out, errBuf bytes.Buffer
|
var out, errBuf bytes.Buffer
|
||||||
code := runCLI([]string{"say", "--help"}, &out, &errBuf)
|
code := runCLI([]string{"say", "--help"}, &out, &errBuf)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
@@ -27,6 +27,12 @@ type sayOptions struct {
|
|||||||
voice string
|
voice string
|
||||||
lang string
|
lang string
|
||||||
provider 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
|
// 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)
|
fmt.Fprintf(stderr, "talk: %v\n", err)
|
||||||
return exitFailure
|
return exitFailure
|
||||||
}
|
}
|
||||||
err = engine.Speak(context.Background(), speak.Request{
|
_, err = engine.Speak(context.Background(), speak.Request{
|
||||||
Text: text,
|
Text: text,
|
||||||
Lang: opts.lang,
|
Lang: opts.lang,
|
||||||
Provider: opts.provider,
|
Provider: opts.provider,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,3 +54,13 @@ func dirFor(goos string, getenv func(string) string) (string, error) {
|
|||||||
return filepath.Join(home, ".config", "talk"), nil
|
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
|
||||||
|
}
|
||||||
@@ -90,12 +90,17 @@ func Run(ctx context.Context, opts Options) error {
|
|||||||
MaxText: cfg.Queue.MaxText,
|
MaxText: cfg.Queue.MaxText,
|
||||||
MaxAge: cfg.Queue.MaxAge,
|
MaxAge: cfg.Queue.MaxAge,
|
||||||
}, func(ctx context.Context, item queue.Item) error {
|
}, func(ctx context.Context, item queue.Item) error {
|
||||||
return engine.Speak(ctx, speak.Request{
|
out, err := engine.Speak(ctx, speak.Request{
|
||||||
Text: item.Text,
|
Text: item.Text,
|
||||||
Lang: item.Lang,
|
Lang: item.Lang,
|
||||||
Provider: item.Provider,
|
Provider: item.Provider,
|
||||||
Voice: item.Voice,
|
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)
|
}, lw)
|
||||||
|
|
||||||
// Persistenter Mute-State und PID-Datei im Config-Dir. Ohne Config-Dir
|
// Persistenter Mute-State und PID-Datei im Config-Dir. Ohne Config-Dir
|
||||||
|
|||||||
@@ -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])
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DefaultOrder ist die bindende Default-Fallback-Reihenfolge der Kette
|
// 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
|
// statt sie wegzuwerfen und neu zu laufen. Nur ein Fehler aller Stufen
|
||||||
// oder eine fehlgeschlagene Wiedergabe sind Fehler.
|
// oder eine fehlgeschlagene Wiedergabe sind Fehler.
|
||||||
func (c *Chain) Speak(ctx context.Context, text, lang string) error {
|
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)
|
r := c.walk(ctx, text, lang)
|
||||||
|
synth := time.Since(start)
|
||||||
switch {
|
switch {
|
||||||
case r.data != nil:
|
case r.data != nil:
|
||||||
if c.cfg.Player == 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 {
|
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:
|
case r.direct != nil:
|
||||||
if c.cfg.Player != nil && c.cfg.Player.Name() == NullPlayerName {
|
if c.cfg.Player != nil && c.cfg.Player.Name() == NullPlayerName {
|
||||||
chainLogf(c.cfg, "kette: Direktansage verworfen (audio.sink=null)\n")
|
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:
|
default:
|
||||||
return r.noTierErr()
|
return Outcome{}, r.noTierErr()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) —
|
// Synthesize implementiert Provider. Rückgabeformat: RIFF/WAVE (PCM) —
|
||||||
// abspielbar über jeden Sink des audio-Pakets.
|
// abspielbar über jeden Sink des audio-Pakets.
|
||||||
func (p OSBuiltin) Synthesize(ctx context.Context, text, lang string) ([]byte, error) {
|
func (p OSBuiltin) Synthesize(ctx context.Context, text, lang string) ([]byte, error) {
|
||||||
|
|||||||
@@ -64,19 +64,27 @@ func New(cfg config.Config, getenv func(string) string, log io.Writer) (*Engine,
|
|||||||
|
|
||||||
// Speak spricht eine Anfrage: Sprache erkennen (falls nicht gepinnt), Kette
|
// Speak spricht eine Anfrage: Sprache erkennen (falls nicht gepinnt), Kette
|
||||||
// bauen, synthetisieren mit Fallback, über den Sink abspielen. Blockiert
|
// bauen, synthetisieren mit Fallback, über den Sink abspielen. Blockiert
|
||||||
// bis zum Ende der Wiedergabe oder bis ctx abgebrochen wird.
|
// bis zum Ende der Wiedergabe oder bis ctx abgebrochen wird. Das Outcome
|
||||||
func (e *Engine) Speak(ctx context.Context, req Request) error {
|
// 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
|
lang := req.Lang
|
||||||
if lang == "" {
|
if lang == "" {
|
||||||
lang = langdetect.Detect(req.Text)
|
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))
|
chain, err := provider.NewChain(chainConfig(e.cfg, req.Provider, req.Voice, e.getenv, e.sink, e.log))
|
||||||
if err != nil {
|
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,
|
// ActiveProvider löst den „aktiven Provider" für eine Anfrage auf: der Pin,
|
||||||
// sonst die erste verfügbare Stufe der Basiskette (Available ist billig —
|
// sonst die erste verfügbare Stufe der Basiskette (Available ist billig —
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user