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
+1639
-63
No files matched your search
@@ -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"
|
||||
"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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user