feat(cli): say-Pfad über die Provider-Kette

runSay baut jetzt die Kette aus Config + Flags (Pin: --provider schlägt
config provider; --voice wird über VoicePinner durchgepinnt) und ruft
chain.Speak — Synthese mit Fallback, Wiedergabe über den Sink, Direkt-
ansage inklusive tonlosem Verwerfen am null-Sink steckt komplett in der
Kette (wiederverwendbar für den Daemon aus Task 04).

Zusätzlich: Flags dürfen jetzt überall stehen — Go-stdlib-flag stoppt
bei dem ersten Nicht-Flag-Argument, die Abnahme-Aufrufform ist aber
`talk "Test" --provider=elevenlabs`. parseInterspersed parst wiederholt
über den Rest; Positionsargumente (der Text) bleiben vollständig.

Tests: Cloud-Provider-End-to-End über die Env-Seam gegen httptest-Fakes,
--voice im Request-Pfad sichtbar, 500-Fallback bis osbuiltin auf CLI-
Ebene, Flag-nach-Text-Regression.
This commit is contained in:
m3ta-chiron committed 2026-10-18 16:34:54 +02:00
1 parent 91cd0ec8da
commit 28b4e558c3
3 files changed
+255 -53

No files matched your search

+32 -6
View File
@@ -145,6 +145,29 @@ func run(argv []string, stdin io.Reader, stdout, stderr io.Writer) int {
return runDefault(argv, stdin, stdout, stderr)
}
// parseInterspersed parst Flags, die überall in argv stehen dürfen. Die
// Go-stdlib-flag stoppt beim ersten Nicht-Flag-AArgument; die CLI-Surface
// verlangt aber `talk "Text" --provider=x` (Abnahmekriterium Task 03)
// genauso wie `talk --provider=x "Text"`. Trick: Parse über den Rest
// wiederholen und jeweils das Positionsargument vor dem Weiterparsen
// entnehmen. Fehler (unbekannte Flags, -h) kommen wie gewohnt vom FlagSet.
func parseInterspersed(fs *flag.FlagSet, argv []string) ([]string, error) {
var positional []string
rest := argv
for {
if err := fs.Parse(rest); err != nil {
return nil, err
}
args := fs.Args()
if len(args) == 0 {
return positional, nil
}
// args[0] ist das Argument, an dem Parse gestoppt hat.
positional = append(positional, args[0])
rest = args[1:]
}
}
func lookupCommand(name string) (command, bool) {
for _, cmd := range commands {
if cmd.name == name {
@@ -171,10 +194,12 @@ func newCommandFlagSet(cmd command, out io.Writer) (*flag.FlagSet, *bool, *sayOp
}
// runCommand führt einen benannten Subcommand aus. say ist implementiert
// (Task 02); alle anderen sind Stubs bis zu ihren Tasks.
// (Task 02); alle anderen sind Stubs bis zu ihren Tasks. Flags dürfen auch
// nach dem Text stehen (parseInterspersed).
func runCommand(cmd command, args []string, stdin io.Reader, stdout, stderr io.Writer) int {
fs, help, sayOpts := newCommandFlagSet(cmd, stderr)
if err := fs.Parse(args); err != nil {
positional, err := parseInterspersed(fs, args)
if err != nil {
if errors.Is(err, flag.ErrHelp) { // z. B. nacktes -h
writeCommandHelp(cmd, stdout)
return exitOK
@@ -188,13 +213,13 @@ func runCommand(cmd command, args []string, stdin io.Reader, stdout, stderr io.W
return exitOK
}
if cmd.handler != nil {
return cmd.handler(fs.Args(), sayOpts, stdin, stderr)
return cmd.handler(positional, sayOpts, stdin, stderr)
}
return notImplemented(cmd.name, stderr)
}
// runDefault behandelt Aufrufe ohne bekannten Subcommand: `talk "Text"`,
// `talk --version`, `talk --help` sowie say-Flags vor dem Text.
// `talk --version`, `talk --help` sowie say-Flags vor oder nach dem Text.
func runDefault(argv []string, stdin io.Reader, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("talk", flag.ContinueOnError)
fs.SetOutput(stderr)
@@ -203,7 +228,8 @@ func runDefault(argv []string, stdin io.Reader, stdout, stderr io.Writer) int {
help := fs.Bool("help", false, "diese Hilfe anzeigen")
sayOpts := addSayFlags(fs)
if err := fs.Parse(argv); err != nil {
positional, err := parseInterspersed(fs, argv)
if err != nil {
if errors.Is(err, flag.ErrHelp) {
writeUsage(stdout)
return exitOK
@@ -219,7 +245,7 @@ func runDefault(argv []string, stdin io.Reader, stdout, stderr io.Writer) int {
writeUsage(stdout)
return exitOK
}
return runSay(fs.Args(), sayOpts, stdin, stderr)
return runSay(positional, sayOpts, stdin, stderr)
}
// versionString rendert die Versionsausgabe: `talk version <version>` plus
+44 -40
View File
@@ -2,7 +2,6 @@ package main
import (
"context"
"errors"
"fmt"
"io"
"os"
@@ -29,13 +28,14 @@ type sayOptions struct {
provider string
}
// runSay ist der komplette Ansage-Pfad von Task 02 (direkt und blockierend;
// runSay ist der komplette Ansage-Pfad (direkt und blockierend;
// Daemon-Anbindung folgt in Task 04): Text aus Argumenten oder stdin →
// Spracherkennung → Provider (Default osbuiltin; Kette folgt Task 03) →
// Synthesize → Sink.Play. Auf Plattformen, deren OS-TTS keine Bytes liefern
// kann (Linux ohne espeak), springt die dokumentierte Erweiterung
// provider.DirectSpeaker an (spd-say); mit audio.sink=null wird die
// Ansage stattdessen verworfen — CI/Server bleiben ton- und fehlerfrei.
// Spracherkennung → Provider-Kette (Task 03: elevenlabs → openai → edge →
// osbuiltin; --provider/config provider pinnen) → Synthese mit Fallback →
// Sink.Play. Auf Plattformen, deren OS-TTS keine Bytes liefern kann (Linux
// ohne espeak), springt die dokumentierte Erweiterung provider.DirectSpeaker
// an (spd-say); mit audio.sink=null wird die Ansage stattdessen verworfen —
// CI/Server bleiben ton- und fehlerfrei.
func runSay(args []string, opts *sayOptions, stdin io.Reader, stderr io.Writer) int {
text := strings.Join(args, " ")
if text == "" {
@@ -63,9 +63,6 @@ func runSay(args []string, opts *sayOptions, stdin io.Reader, stderr io.Writer)
fmt.Fprintf(stderr, "talk: --lang: ungültiger Wert %q (erlaubt: de, en)\n", lang)
return exitFailure
}
if opts.voice != "" {
fmt.Fprintln(stderr, "talk: Hinweis: --voice wirkt ab Task 03 (Voice-Registry) und wird für diese Ansage ignoriert")
}
cfg, err := config.Load(config.Options{Getenv: getenv})
if err != nil {
@@ -73,49 +70,56 @@ func runSay(args []string, opts *sayOptions, stdin io.Reader, stderr io.Writer)
return exitFailure
}
p, err := provider.New(opts.provider)
// Sink zuerst: Die Kette braucht ihn als Player (Bytes-Wiedergabe) und
// für die null-Entscheidung bei der Direktansage.
sink, err := audio.NewSink(cfg.Audio.Sink, stderr)
if err != nil {
fmt.Fprintf(stderr, "talk: %v\n", err)
return exitFailure
}
chain, err := provider.NewChain(provider.ChainConfig{
Pin: pickProvider(opts.provider, cfg.Provider),
Voice: opts.voice,
Voices: cfg.Voice,
Endpoints: endpointsOf(cfg),
Model: cfg.Providers["openai"].Model,
Getenv: getenv,
Player: sink,
Log: stderr,
})
if err != nil {
fmt.Fprintf(stderr, "talk: %v\n", err)
return exitFailure
}
ctx := context.Background()
if !p.Available(ctx) {
fmt.Fprintf(stderr, "talk: Provider %s ist auf diesem System nicht verfügbar (keine passende OS-TTS-Runtime gefunden)\n", p.Name())
if err := chain.Speak(ctx, text, lang); err != nil {
fmt.Fprintf(stderr, "talk: %v\n", err)
return exitFailure
}
return exitOK
}
data, err := p.Synthesize(ctx, text, lang)
if err != nil {
if errors.Is(err, provider.ErrNoByteOutput) {
if cfg.Audio.Sink == config.SinkNull {
// null-Sink verwirft die Ansage grundsätzlich — auch die
// Direktansage würde Ton erzeugen und entfällt somit.
fmt.Fprintln(stderr, "talk: Ansage verworfen (audio.sink=null)")
return exitOK
}
if ds, ok := p.(provider.DirectSpeaker); ok {
if err := ds.SpeakDirect(ctx, text, lang); err != nil {
fmt.Fprintf(stderr, "talk: %v\n", err)
return exitFailure
}
return exitOK
}
}
fmt.Fprintf(stderr, "talk: %v\n", err)
return exitFailure
// pickProvider löst den Provider-Pin auf: CLI-Flag --provider schlägt
// `provider = "…"` aus der config.toml; leer = komplette Default-Kette.
func pickProvider(flagValue, cfgValue string) string {
if flagValue != "" {
return flagValue
}
return cfgValue
}
sink, err := audio.NewSink(cfg.Audio.Sink, stderr)
if err != nil {
fmt.Fprintf(stderr, "talk: %v\n", err)
return exitFailure
// endpointsOf überführt die Provider-Einstellungen der Config in die
// Endpunkt-Map der Kette.
func endpointsOf(cfg config.Config) map[string]string {
endpoints := make(map[string]string, len(cfg.Providers))
for name, settings := range cfg.Providers {
if settings.Endpoint != "" {
endpoints[name] = settings.Endpoint
}
if err := sink.Play(ctx, data); err != nil {
fmt.Fprintf(stderr, "talk: Wiedergabe über %s fehlgeschlagen: %v\n", sink.Name(), err)
return exitFailure
}
return exitOK
return endpoints
}
// isCharDevice meldet, ob r ein Terminal ist (dann darf talk nicht auf
+179 -7
View File
@@ -2,18 +2,28 @@ package main
import (
"bytes"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
// setGetenv überschreibt die Env-Seam der CLI für einen Test.
// setGetenv überschreibt die Env-Seam der CLI für einen Test. Grundaus-
// stattung jedes Tests: TALK_EDGE_ENDPOINT zeigt auf einen garantiert
// toten Loopback-Port (Verbindung wird sofort abgelehnt) — die Default-
// Kette enthält edge, und kein Test darf je den echten Microsoft-Endpunkt
// anfassen. Wer edge bewusst anders braucht, überschreibt den Key.
func setGetenv(t *testing.T, kv map[string]string) {
t.Helper()
base := map[string]string{"TALK_EDGE_ENDPOINT": "ws://127.0.0.1:1/"}
for k, v := range kv {
base[k] = v
}
old := getenv
getenv = func(k string) string {
if v, ok := kv[k]; ok {
if v, ok := base[k]; ok {
return v
}
return ""
@@ -158,17 +168,17 @@ func TestSayUngueltigeSprache(t *testing.T) {
}
}
// TestSayUnbekannterProvider: --provider mit noch nicht implementiertem
// Namen ist ein klarer Fehler (Task 03 ergänzt die Kette).
// TestSayUnbekannterProvider: --provider mit unbekanntem Namen ist ein
// klarer Fehler, der die verfügbaren Provider nennt (Task 03: Kette).
func TestSayUnbekannterProvider(t *testing.T) {
withFakeEspeak(t)
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"})
var out, errBuf bytes.Buffer
code := runCLI([]string{"--provider=elevenlabs", "Test"}, &out, &errBuf)
code := runCLI([]string{"--provider=gibtsnicht", "Test"}, &out, &errBuf)
if code != exitFailure {
t.Fatalf("talk --provider=elevenlabs: Exit-Code = %d, want %d", code, exitFailure)
t.Fatalf("talk --provider=gibtsnicht: Exit-Code = %d, want %d", code, exitFailure)
}
if !strings.Contains(errBuf.String(), "elevenlabs") || !strings.Contains(errBuf.String(), "osbuiltin") {
if !strings.Contains(errBuf.String(), "gibtsnicht") || !strings.Contains(errBuf.String(), "osbuiltin") || !strings.Contains(errBuf.String(), "edge") {
t.Errorf("stderr = %q, will Provider-Fehler mit verfügbaren Namen", errBuf.String())
}
}
@@ -250,3 +260,165 @@ func TestSayDirektesFallbeckenMitOtoSink(t *testing.T) {
t.Errorf("spd-say ARGS = %q, want %q", got, "-w -l de Hallo Welt")
}
}
// fakeCloudServer startet einen httptest-HTTP-Fake, der status mit body
// antwortet und den letzten Request aufzeichnet (Struktur wie im
// provider-Paket; die CLI-Tests beobachten nur extern: Exit-Code, stderr,
// ob der Fake gerufen wurde und mit welchem Pfad).
type fakeCloudReq struct {
path string
apiKey string
}
func fakeCloudServer(t *testing.T, status int, body []byte) (url string, reqs chan fakeCloudReq) {
t.Helper()
reqs = make(chan fakeCloudReq, 8)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reqs <- fakeCloudReq{path: r.URL.Path, apiKey: r.Header.Get("xi-api-key")}
w.WriteHeader(status)
w.Write(body)
}))
t.Cleanup(ts.Close)
return ts.URL, reqs
}
// TestSayCloudProviderMitFake: `talk "Text" --provider=elevenlabs` mit Key
// + Fake-Endpunkt und sink=null läuft mit Exit 0 durch — Synthese lief
// (Fake gerufen, korrekter Pfad inkl. neutraler Default-Stimme, Key im
// Header), Wiedergabe verworfen.
func TestSayCloudProviderMitFake(t *testing.T) {
url, reqs := fakeCloudServer(t, 200, []byte("\xff\xfb\x90\x00mp3"))
setGetenv(t, map[string]string{
"TALK_AUDIO_SINK": "null",
"TALK_ELEVENLABS_KEY": "cli-testkey",
"TALK_ELEVENLABS_ENDPOINT": url,
})
var out, errBuf bytes.Buffer
code := runCLI([]string{"--provider=elevenlabs", "Test"}, &out, &errBuf)
if code != exitOK {
t.Fatalf("talk --provider=elevenlabs (Fake): Exit-Code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
}
select {
case req := <-reqs:
if want := "/v1/text-to-speech/21m00Tcm4TlvDq8ikWAM"; req.path != want {
t.Errorf("Pfad = %q, want %q (neutrales Default)", req.path, want)
}
if req.apiKey != "cli-testkey" {
t.Errorf("xi-api-key = %q", req.apiKey)
}
default:
t.Error("Fake-Endpunkt wurde nicht gerufen")
}
if !strings.Contains(errBuf.String(), "verworfen (null sink)") {
t.Errorf("stderr = %q, will Verwurf-Zeile", errBuf.String())
}
}
// TestSayVoiceFlagAmCloudProvider: --voice landet im Request-Pfad.
func TestSayVoiceFlagAmCloudProvider(t *testing.T) {
url, reqs := fakeCloudServer(t, 200, []byte("\xff\xfb\x90\x00mp3"))
setGetenv(t, map[string]string{
"TALK_AUDIO_SINK": "null",
"TALK_ELEVENLABS_KEY": "k",
"TALK_ELEVENLABS_ENDPOINT": url,
})
var out, errBuf bytes.Buffer
if code := runCLI([]string{"--provider=elevenlabs", "--voice=MeineStimme", "Test"}, &out, &errBuf); code != exitOK {
t.Fatalf("Exit-Code = %d, stderr = %q", code, errBuf.String())
}
select {
case req := <-reqs:
if want := "/v1/text-to-speech/MeineStimme"; req.path != want {
t.Errorf("Pfad = %q, want %q", req.path, want)
}
default:
t.Error("Fake-Endpunkt wurde nicht gerufen")
}
}
// TestSayCloudOhneKey: gepinnter Cloud-Provider ohne Key → klarer Fehler
// (Stufe nicht verfügbar, Exit 1).
func TestSayCloudOhneKey(t *testing.T) {
url, _ := fakeCloudServer(t, 200, []byte("x"))
setGetenv(t, map[string]string{
"TALK_AUDIO_SINK": "null",
"TALK_ELEVENLABS_ENDPOINT": url, // Endpunkt, aber kein Key
})
var out, errBuf bytes.Buffer
code := runCLI([]string{"--provider=elevenlabs", "Test"}, &out, &errBuf)
if code != exitFailure {
t.Fatalf("Exit-Code = %d, want %d", code, exitFailure)
}
if !strings.Contains(errBuf.String(), "nicht verfügbar") {
t.Errorf("stderr = %q, will Verfügbarkeits-Hinweis", errBuf.String())
}
}
// TestSayCloud500FaelltAufOsbuiltin: gepinnt wäre kurzfristig — hier ohne
// Pin: elevenlabs (Fake, 500) → openai (kein Key) → edge (tot) → osbuiltin
// (fake espeak) liefert die Bytes. Der CLI-Ketten-Fallback ist end-to-end
// über die Env-Seam beobachtbar.
func TestSayCloud500FaelltAufOsbuiltin(t *testing.T) {
url, _ := fakeCloudServer(t, 500, []byte("boom"))
seen := withFakeEspeak(t)
setGetenv(t, map[string]string{
"TALK_AUDIO_SINK": "null",
"TALK_ELEVENLABS_KEY": "k",
"TALK_ELEVENLABS_ENDPOINT": url,
})
var out, errBuf bytes.Buffer
code := runCLI([]string{"Ketten Test"}, &out, &errBuf)
if code != exitOK {
t.Fatalf("talk mit 500-Cloud-Stufe: Exit-Code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
}
if _, err := os.Stat(seen); err != nil {
t.Errorf("fake espeak nicht gerufen (Kette nicht bis osbuiltin gefallen): %v", err)
}
if !strings.Contains(errBuf.String(), "500") || !strings.Contains(errBuf.String(), "Fallback") {
t.Errorf("stderr = %q, will sichtbaren 500-Fallback", errBuf.String())
}
}
// TestSayFlagNachDemText: `talk "Text" --provider=…` (Flag NACH dem Text,
// Abnahme-Aufrufform) pinnt den Provider — nur die gepinnte Stufe wird
// angefragt.
func TestSayFlagNachDemText(t *testing.T) {
url, reqs := fakeCloudServer(t, 200, []byte("\xff\xfb\x90\x00mp3"))
setGetenv(t, map[string]string{
"TALK_AUDIO_SINK": "null",
"TALK_ELEVENLABS_KEY": "k",
"TALK_ELEVENLABS_ENDPOINT": url,
})
var out, errBuf bytes.Buffer
code := runCLI([]string{"Test ansage", "--provider=elevenlabs"}, &out, &errBuf)
if code != exitOK {
t.Fatalf("talk \"Text\" --provider=elevenlabs: Exit-Code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
}
select {
case req := <-reqs:
if !strings.Contains(req.path, "/v1/text-to-speech/") {
t.Errorf("Pfad = %q, want TTS-Pfad", req.path)
}
default:
t.Error("Fake-Endpunkt wurde nicht gerufen")
}
// Zwei Argumente mit verteilten Flags: Text bleibt vollständig.
seen := withFakeEspeak(t)
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"})
code = runCLI([]string{"Erster", "--lang=en", "zweiter Teil"}, &out, &errBuf)
if code != exitOK {
t.Fatalf("talk Erster --lang=en zweiter Teil: Exit-Code = %d, stderr = %q", code, errBuf.String())
}
args, err := os.ReadFile(seen)
if err != nil {
t.Fatalf("fake espeak nicht gerufen: %v", err)
}
if got := strings.TrimSpace(string(args)); got != "-v en --stdout Erster zweiter Teil" {
t.Errorf("espeak ARGS = %q, want %q", got, "-v en --stdout Erster zweiter Teil")
}
}