diff --git a/cmd/talk/cli.go b/cmd/talk/cli.go index d72134e..30021a0 100644 --- a/cmd/talk/cli.go +++ b/cmd/talk/cli.go @@ -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 ` plus diff --git a/cmd/talk/say.go b/cmd/talk/say.go index d0f262b..a9bd3d7 100644 --- a/cmd/talk/say.go +++ b/cmd/talk/say.go @@ -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,51 +70,58 @@ 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()) - return exitFailure - } - - 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 - } - } + if err := chain.Speak(ctx, text, lang); err != nil { fmt.Fprintf(stderr, "talk: %v\n", err) return exitFailure } - - sink, err := audio.NewSink(cfg.Audio.Sink, stderr) - if err != nil { - fmt.Fprintf(stderr, "talk: %v\n", err) - return exitFailure - } - 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 } +// 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 +} + +// 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 + } + } + return endpoints +} + // isCharDevice meldet, ob r ein Terminal ist (dann darf talk nicht auf // stdin-Input warten, sondern meldet einen Benutzungsfehler). func isCharDevice(r io.Reader) bool { diff --git a/cmd/talk/say_test.go b/cmd/talk/say_test.go index 141c3bb..c515eab 100644 --- a/cmd/talk/say_test.go +++ b/cmd/talk/say_test.go @@ -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") + } +}