feat(cli): say-Pfad end-to-end — erste Ansage über OS-TTS
Text aus Argument oder stdin → langdetect → Provider (Default osbuiltin; --provider existiert, Kette folgt Task 03) → Synthesize → Sink.Play, blockierend. Ohne byte-fähiges OS-TTS (Linux ohne espeak): Direktansage über DirectSpeaker (spd-say); mit audio.sink=null wird die Ansage stattdessen verworfen — CI/Server laufen tonlos mit Exit 0 durch. run() bekommt stdin als Reader-Seam; an einem Terminal ohne Text gibt es einen Benutzungsfehler statt blockierendem Warten. say-Hilfe zeigt nicht mehr den Stub-Hinweis; alle anderen Befehle bleiben Stubs.
This commit is contained in:
1 parent
523e6f4bf2
commit
8d42163aac
5 files changed
+465
-66
No files matched your search
+56
-33
@@ -17,13 +17,16 @@ const (
|
||||
// command ist ein Eintrag der Befehlstabelle. Die Tabelle ist bewusst
|
||||
// datengetrieben: Spätere Tasks (z. B. die TUI-Befehle aus Task 09) ergänzen
|
||||
// hier Einträge bzw. Register-Funktionen, ohne die Dispatch-Logik in run()
|
||||
// anzufassen.
|
||||
// anzufassen. register hängt die Flags des Befehls an den FlagSet und liefert
|
||||
// die gebundenen say-Options (derzeit der einzige Befehl mit eigenen Flags;
|
||||
// erhält ein weiterer Befehl eigene Flags, wird die Signatur verallgemeinert).
|
||||
type command struct {
|
||||
name string // Subcommand-Name (bindende CLI-Surface aus der Spec)
|
||||
short string // Kurzbeschreibung für `talk --help`
|
||||
args string // Positionsargumente-Hinweis für die Usage-Zeile
|
||||
detail string // Beschreibung für `talk <Befehl> --help`
|
||||
register func(fs *flag.FlagSet) // hängt die Flags des Befehls an den FlagSet
|
||||
register func(fs *flag.FlagSet) *sayOptions // hängt die Flags des Befehls an den FlagSet
|
||||
handler func(args []string, o *sayOptions, stdin io.Reader, stderr io.Writer) int // Ausführung (fehlt sie: Stub)
|
||||
}
|
||||
|
||||
// commands ist die vollständige Befehlstabelle. Die Namen sind Teil der
|
||||
@@ -35,6 +38,7 @@ var commands = []command{
|
||||
args: `"Text"`,
|
||||
detail: "Spricht den Text über die Provider-Kette aus. Ohne Text-Argument wird stdin gelesen. `talk \"Text\"` ist die Abkürzung für `talk say \"Text\"`.",
|
||||
register: addSayFlags,
|
||||
handler: runSay,
|
||||
},
|
||||
{
|
||||
name: "wrap",
|
||||
@@ -84,29 +88,40 @@ var commands = []command{
|
||||
},
|
||||
}
|
||||
|
||||
// addSayFlags registriert die Flags des say-Befehls. Sie gelten auch für den
|
||||
// Default-Pfad `talk "Text"` (gleiche Flag-Oberfläche, festgelegt in der
|
||||
// Spec). Das Verhalten entsteht in Task 02 (direkter Pfad) bzw. Task 04
|
||||
// (Daemon-Pfad).
|
||||
func addSayFlags(fs *flag.FlagSet) {
|
||||
fs.Bool("wait", false, "warten, bis die Ansage fertig abgespielt ist")
|
||||
fs.Bool("now", false, "Ansage sofort ausgeben (springt die Warteschlange)")
|
||||
fs.String("priority", "normal", "Priorität: low | normal | high")
|
||||
fs.String("voice", "", "Stimme als provider-spezifische ID")
|
||||
fs.String("lang", "", "Sprache: de | en (Default: automatische Erkennung)")
|
||||
fs.String("provider", "", "Provider pinnen: elevenlabs | openai | edge | osbuiltin")
|
||||
// addSayFlags registriert die Flags des say-Befehls und bindet sie an die
|
||||
// zurückgegebene Options-Struct. Sie gelten auch für den Default-Pfad
|
||||
// `talk "Text"` (gleiche Flag-Oberfläche, festgelegt in der Spec). Das
|
||||
// Verhalten: direkter Pfad in Task 02, Daemon-Pfad in Task 04.
|
||||
//
|
||||
// Rückgabe ist bewusst ein Pointer: flag.Var bindet an die Felder genau
|
||||
// dieser Struct — eine Wertkopie hätte nach dem Parse weiterhin die
|
||||
// Defaults.
|
||||
func addSayFlags(fs *flag.FlagSet) *sayOptions {
|
||||
o := &sayOptions{}
|
||||
fs.BoolVar(&o.wait, "wait", false, "warten, bis die Ansage fertig abgespielt ist")
|
||||
fs.BoolVar(&o.now, "now", false, "Ansage sofort ausgeben (springt die Warteschlange)")
|
||||
fs.StringVar(&o.priority, "priority", "normal", "Priorität: low | normal | high")
|
||||
fs.StringVar(&o.voice, "voice", "", "Stimme als provider-spezifische ID")
|
||||
fs.StringVar(&o.lang, "lang", "", "Sprache: de | en (Default: automatische Erkennung)")
|
||||
fs.StringVar(&o.provider, "provider", "", "Provider pinnen: elevenlabs | openai | edge | osbuiltin")
|
||||
return o
|
||||
}
|
||||
|
||||
// run ist der Einstiegspunkt der CLI (Argumente ohne Programmnamen). Alle
|
||||
// Ausgaben gehen auf die übergebenen Writer, damit run direkt und ohne
|
||||
// Prozessstart testbar ist. Rückgabe ist der Exit-Code.
|
||||
func run(argv []string, stdout, stderr io.Writer) int {
|
||||
// Ausgaben gehen auf die übergebenen Writer, stdin ist als Reader injiziert
|
||||
// — damit ist run direkt und ohne Prozessstart testbar (auch der stdin-
|
||||
// Pfad aus Task 02). Rückgabe ist der Exit-Code.
|
||||
func run(argv []string, stdin io.Reader, stdout, stderr io.Writer) int {
|
||||
if len(argv) == 0 {
|
||||
// Task 02 macht aus dem nackten Aufruf ggf. einen stdin-Leser
|
||||
// (Text kommt dann über die Pipe); bis dahin: Benutzungsfehler.
|
||||
// Kein Befehl und kein Argument: Der Text könnte über stdin
|
||||
// kommen (Pipe). An einem Terminal gibt es keinen Text — dann
|
||||
// Benutzungsfehler, damit talk nie auf Eingaben wartet.
|
||||
if isCharDevice(stdin) {
|
||||
writeUsage(stderr)
|
||||
return exitUsage
|
||||
}
|
||||
return runSay(nil, &sayOptions{}, stdin, stderr)
|
||||
}
|
||||
|
||||
if argv[0] == "help" {
|
||||
if len(argv) > 1 {
|
||||
@@ -122,12 +137,12 @@ func run(argv []string, stdout, stderr io.Writer) int {
|
||||
}
|
||||
|
||||
if cmd, ok := lookupCommand(argv[0]); ok {
|
||||
return runCommand(cmd, argv[1:], stdout, stderr)
|
||||
return runCommand(cmd, argv[1:], stdin, stdout, stderr)
|
||||
}
|
||||
|
||||
// Kein bekannter Befehl: Default-Pfad `talk "Text"` (mappt auf say)
|
||||
// bzw. Root-Flags (--version, --help). Führt aktuell auf den say-Stub.
|
||||
return runDefault(argv, stdout, stderr)
|
||||
// bzw. Root-Flags (--version, --help).
|
||||
return runDefault(argv, stdin, stdout, stderr)
|
||||
}
|
||||
|
||||
func lookupCommand(name string) (command, bool) {
|
||||
@@ -140,22 +155,25 @@ func lookupCommand(name string) (command, bool) {
|
||||
}
|
||||
|
||||
// newCommandFlagSet baut den FlagSet eines Befehls: eigene Flags plus ein
|
||||
// explizites --help. Die Default-Usage wird stummgeschaltet, damit Hilfetext
|
||||
// ausschließlich über writeCommandHelp (eine Quelle) ausgegeben wird.
|
||||
func newCommandFlagSet(cmd command, out io.Writer) (*flag.FlagSet, *bool) {
|
||||
// explizites --help und die gebundenen say-Options. Die Default-Usage wird
|
||||
// stummgeschaltet, damit Hilfetext ausschließlich über writeCommandHelp
|
||||
// (eine Quelle) ausgegeben wird.
|
||||
func newCommandFlagSet(cmd command, out io.Writer) (*flag.FlagSet, *bool, *sayOptions) {
|
||||
fs := flag.NewFlagSet("talk "+cmd.name, flag.ContinueOnError)
|
||||
fs.SetOutput(out)
|
||||
fs.Usage = func() {}
|
||||
var sayOpts *sayOptions
|
||||
if cmd.register != nil {
|
||||
cmd.register(fs)
|
||||
sayOpts = cmd.register(fs)
|
||||
}
|
||||
help := fs.Bool("help", false, "Hilfe zu diesem Befehl anzeigen")
|
||||
return fs, help
|
||||
return fs, help, sayOpts
|
||||
}
|
||||
|
||||
// runCommand führt einen benannten Subcommand aus — in diesem Stand als Stub.
|
||||
func runCommand(cmd command, args []string, stdout, stderr io.Writer) int {
|
||||
fs, help := newCommandFlagSet(cmd, stderr)
|
||||
// runCommand führt einen benannten Subcommand aus. say ist implementiert
|
||||
// (Task 02); alle anderen sind Stubs bis zu ihren Tasks.
|
||||
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 {
|
||||
if errors.Is(err, flag.ErrHelp) { // z. B. nacktes -h
|
||||
writeCommandHelp(cmd, stdout)
|
||||
@@ -169,18 +187,21 @@ func runCommand(cmd command, args []string, stdout, stderr io.Writer) int {
|
||||
writeCommandHelp(cmd, stdout)
|
||||
return exitOK
|
||||
}
|
||||
if cmd.handler != nil {
|
||||
return cmd.handler(fs.Args(), 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.
|
||||
func runDefault(argv []string, stdout, stderr io.Writer) int {
|
||||
func runDefault(argv []string, stdin io.Reader, stdout, stderr io.Writer) int {
|
||||
fs := flag.NewFlagSet("talk", flag.ContinueOnError)
|
||||
fs.SetOutput(stderr)
|
||||
fs.Usage = func() {}
|
||||
showVersion := fs.Bool("version", false, "Version anzeigen")
|
||||
help := fs.Bool("help", false, "diese Hilfe anzeigen")
|
||||
addSayFlags(fs)
|
||||
sayOpts := addSayFlags(fs)
|
||||
|
||||
if err := fs.Parse(argv); err != nil {
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
@@ -198,7 +219,7 @@ func runDefault(argv []string, stdout, stderr io.Writer) int {
|
||||
writeUsage(stdout)
|
||||
return exitOK
|
||||
}
|
||||
return notImplemented("say", stderr)
|
||||
return runSay(fs.Args(), sayOpts, stdin, stderr)
|
||||
}
|
||||
|
||||
// versionString rendert die Versionsausgabe: `talk version <version>` plus
|
||||
@@ -253,7 +274,9 @@ func writeCommandHelp(cmd command, w io.Writer) {
|
||||
fmt.Fprintf(w, "%s\n\n", cmd.detail)
|
||||
}
|
||||
fmt.Fprintln(w, "Flags:")
|
||||
fs, _ := newCommandFlagSet(cmd, w)
|
||||
fs, _, _ := newCommandFlagSet(cmd, w)
|
||||
fs.PrintDefaults()
|
||||
if cmd.handler == nil {
|
||||
fmt.Fprintln(w, "\nStatus: noch nicht implementiert (WIP).")
|
||||
}
|
||||
}
|
||||
+24
-30
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -13,9 +14,21 @@ var wantSubcommands = []string{
|
||||
"say", "wrap", "mute", "unmute", "status", "cancel", "voices", "test", "doctor", "setup",
|
||||
}
|
||||
|
||||
// wantStubCommands ist wantSubcommands ohne say — say ist seit Task 02
|
||||
// implementiert, alle anderen bleiben Stubs bis zu ihren Tasks.
|
||||
var wantStubCommands = []string{
|
||||
"wrap", "mute", "unmute", "status", "cancel", "voices", "test", "doctor", "setup",
|
||||
}
|
||||
|
||||
// runCLI ist der Test-Einstieg in run mit leerem stdin (kein Text über
|
||||
// Pipe) — dem üblichen Aufruf ohne Eingabe.
|
||||
func runCLI(argv []string, stdout, stderr io.Writer) int {
|
||||
return run(argv, strings.NewReader(""), stdout, stderr)
|
||||
}
|
||||
|
||||
func TestHelpListetAlleSubcommands(t *testing.T) {
|
||||
var out, errBuf bytes.Buffer
|
||||
code := run([]string{"--help"}, &out, &errBuf)
|
||||
code := runCLI([]string{"--help"}, &out, &errBuf)
|
||||
if code != exitOK {
|
||||
t.Fatalf("--help: Exit-Code = %d, want %d (stderr: %q)", code, exitOK, errBuf.String())
|
||||
}
|
||||
@@ -36,7 +49,7 @@ func TestHelpListetAlleSubcommands(t *testing.T) {
|
||||
|
||||
func TestVersionDefault(t *testing.T) {
|
||||
var out, errBuf bytes.Buffer
|
||||
code := run([]string{"--version"}, &out, &errBuf)
|
||||
code := runCLI([]string{"--version"}, &out, &errBuf)
|
||||
if code != exitOK {
|
||||
t.Fatalf("--version: Exit-Code = %d, want %d", code, exitOK)
|
||||
}
|
||||
@@ -56,10 +69,10 @@ func TestVersionStringMitCommit(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSubcommandStubs(t *testing.T) {
|
||||
for _, name := range wantSubcommands {
|
||||
for _, name := range wantStubCommands {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
var out, errBuf bytes.Buffer
|
||||
code := run([]string{name}, &out, &errBuf)
|
||||
code := runCLI([]string{name}, &out, &errBuf)
|
||||
if code == exitOK {
|
||||
t.Errorf("talk %s: Exit-Code 0, want non-zero für Stub", name)
|
||||
}
|
||||
@@ -76,20 +89,9 @@ func TestSubcommandStubs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultAufrufMapptAufSay(t *testing.T) {
|
||||
var out, errBuf bytes.Buffer
|
||||
code := run([]string{"Hallo Welt"}, &out, &errBuf)
|
||||
if code != exitFailure {
|
||||
t.Fatalf(`talk "Hallo Welt": Exit-Code = %d, want %d`, code, exitFailure)
|
||||
}
|
||||
if !strings.Contains(errBuf.String(), "talk say is not implemented yet") {
|
||||
t.Errorf("Default-Aufruf mappt offenbar nicht auf say, stderr = %q", errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSayHelpZeigtFlags(t *testing.T) {
|
||||
var out, errBuf bytes.Buffer
|
||||
code := run([]string{"say", "--help"}, &out, &errBuf)
|
||||
code := runCLI([]string{"say", "--help"}, &out, &errBuf)
|
||||
if code != exitOK {
|
||||
t.Fatalf("talk say --help: Exit-Code = %d, want %d", code, exitOK)
|
||||
}
|
||||
@@ -98,12 +100,15 @@ func TestSayHelpZeigtFlags(t *testing.T) {
|
||||
t.Errorf("talk say --help zeigt Flag %s nicht (Output: %q)", f, out.String())
|
||||
}
|
||||
}
|
||||
if strings.Contains(out.String(), "noch nicht implementiert") {
|
||||
t.Errorf("talk say --help soll say nicht mehr als Stub markieren: %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelpSubcommand(t *testing.T) {
|
||||
t.Run("ohne Argument", func(t *testing.T) {
|
||||
var out, errBuf bytes.Buffer
|
||||
if code := run([]string{"help"}, &out, &errBuf); code != exitOK {
|
||||
if code := runCLI([]string{"help"}, &out, &errBuf); code != exitOK {
|
||||
t.Fatalf("talk help: Exit-Code = %d, want %d", code, exitOK)
|
||||
}
|
||||
for _, name := range wantSubcommands {
|
||||
@@ -114,7 +119,7 @@ func TestHelpSubcommand(t *testing.T) {
|
||||
})
|
||||
t.Run("mit Befehl", func(t *testing.T) {
|
||||
var out, errBuf bytes.Buffer
|
||||
if code := run([]string{"help", "mute"}, &out, &errBuf); code != exitOK {
|
||||
if code := runCLI([]string{"help", "mute"}, &out, &errBuf); code != exitOK {
|
||||
t.Fatalf("talk help mute: Exit-Code = %d, want %d", code, exitOK)
|
||||
}
|
||||
if !strings.Contains(out.String(), "talk mute") {
|
||||
@@ -123,20 +128,9 @@ func TestHelpSubcommand(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestOhneArgumenteZeigtUsage(t *testing.T) {
|
||||
var out, errBuf bytes.Buffer
|
||||
code := run(nil, &out, &errBuf)
|
||||
if code != exitUsage {
|
||||
t.Fatalf("talk ohne Argumente: Exit-Code = %d, want %d", code, exitUsage)
|
||||
}
|
||||
if !strings.Contains(errBuf.String(), "Usage") {
|
||||
t.Errorf("talk ohne Argumente: Usage fehlt auf stderr: %q", errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnbekanntesFlag(t *testing.T) {
|
||||
var out, errBuf bytes.Buffer
|
||||
code := run([]string{"--bogus"}, &out, &errBuf)
|
||||
code := runCLI([]string{"--bogus"}, &out, &errBuf)
|
||||
if code != exitUsage {
|
||||
t.Fatalf("talk --bogus: Exit-Code = %d, want %d", code, exitUsage)
|
||||
}
|
||||
|
||||
+5
-3
@@ -1,7 +1,9 @@
|
||||
// talk ist die CLI für Sprach-Ausgaben (talk 2.0).
|
||||
//
|
||||
// Dieser Stand (Task 01) liefert das Gerüst: Subcommand-Dispatch, --version
|
||||
// und alle Befehle als Stubs. Die Funktionalität entsteht in den Tasks 02–09.
|
||||
// Dieser Stand (Task 02) liefert den say-Pfad end-to-end: Text aus
|
||||
// Argument oder stdin → Spracherkennung → Provider (osbuiltin) →
|
||||
// Wiedergabe über einen Audio-Sink (oto/null). Die übrigen Befehle sind
|
||||
// Stubs; Daemon, Kette und TUI folgen in den Tasks 03–09.
|
||||
package main
|
||||
|
||||
import "os"
|
||||
@@ -17,5 +19,5 @@ var (
|
||||
)
|
||||
|
||||
func main() {
|
||||
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
|
||||
os.Exit(run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr))
|
||||
}
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/audio"
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/config"
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/langdetect"
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/provider"
|
||||
)
|
||||
|
||||
// getenv ist die Env-Seam von run (Tests injizieren hier TALK_AUDIO_SINK
|
||||
// etc., ohne den Prozess-Environment anzufassen). Default: os.Getenv.
|
||||
var getenv = os.Getenv
|
||||
|
||||
// sayOptions bündelt die Flags von say (identisch im Default-Pfad
|
||||
// `talk "Text"`).
|
||||
type sayOptions struct {
|
||||
wait bool
|
||||
now bool
|
||||
priority string
|
||||
voice string
|
||||
lang string
|
||||
provider string
|
||||
}
|
||||
|
||||
// runSay ist der komplette Ansage-Pfad von Task 02 (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.
|
||||
func runSay(args []string, opts *sayOptions, stdin io.Reader, stderr io.Writer) int {
|
||||
text := strings.Join(args, " ")
|
||||
if text == "" {
|
||||
if isCharDevice(stdin) {
|
||||
fmt.Fprintln(stderr, "talk: kein Text — bitte als Argument übergeben oder über stdin pipen")
|
||||
return exitUsage
|
||||
}
|
||||
b, err := io.ReadAll(stdin)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "talk: stdin lesen: %v\n", err)
|
||||
return exitFailure
|
||||
}
|
||||
text = strings.TrimSpace(string(b))
|
||||
if text == "" {
|
||||
fmt.Fprintln(stderr, "talk: kein Text (Argument und stdin sind leer)")
|
||||
return exitFailure
|
||||
}
|
||||
}
|
||||
|
||||
lang := opts.lang
|
||||
if lang == "" {
|
||||
lang = langdetect.Detect(text)
|
||||
}
|
||||
if lang != "de" && lang != "en" {
|
||||
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 {
|
||||
fmt.Fprintf(stderr, "talk: %v\n", err)
|
||||
return exitFailure
|
||||
}
|
||||
|
||||
p, err := provider.New(opts.provider)
|
||||
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
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(stderr, "talk: %v\n", err)
|
||||
return exitFailure
|
||||
}
|
||||
|
||||
sink, err := audio.NewSink(cfg.Audio.Sink)
|
||||
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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
f, ok := r.(*os.File)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
st, err := f.Stat()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return st.Mode()&os.ModeCharDevice != 0
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// setGetenv überschreibt die Env-Seam der CLI für einen Test.
|
||||
func setGetenv(t *testing.T, kv map[string]string) {
|
||||
t.Helper()
|
||||
old := getenv
|
||||
getenv = func(k string) string {
|
||||
if v, ok := kv[k]; ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
t.Cleanup(func() { getenv = old })
|
||||
}
|
||||
|
||||
// fakeTTSBin schreibt ein ausführbares Shell-Skript namens name in dir
|
||||
// (gleicher Mechanismus wie im provider-Paket: echter Prozess, PATH-
|
||||
// Manipulation, kein Mock-Framework).
|
||||
func fakeTTSBin(t *testing.T, dir, name, body string) {
|
||||
t.Helper()
|
||||
script := "#!/bin/sh\n" + body + "\n"
|
||||
path := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// withFakeEspeak stellt ein fake espeak bereit, das WAV-Bytes auf stdout
|
||||
// schreibt und den Aufruf in seen protokolliert. Zurück kommt der Pfad der
|
||||
// Protokolldatei.
|
||||
func withFakeEspeak(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
wav := filepath.Join(dir, "probe.wav")
|
||||
if err := os.WriteFile(wav, []byte("RIFFxxxxWAVEfmt "), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seen := filepath.Join(dir, "seen.txt")
|
||||
fakeTTSBin(t, dir, "espeak", `printf '%s\n' "$*" > "`+seen+`"
|
||||
cat "`+wav+`"`)
|
||||
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
return seen
|
||||
}
|
||||
|
||||
// TestSayArgumentMitNullSink: `talk "Hallo Welt"` läuft mit
|
||||
// TALK_AUDIO_SINK=null tonlos durch (Abnahmekriterium) — Synthese läuft
|
||||
// (fake espeak wurde gerufen), die Wiedergabe wird verworfen.
|
||||
func TestSayArgumentMitNullSink(t *testing.T) {
|
||||
seen := withFakeEspeak(t)
|
||||
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"})
|
||||
|
||||
var out, errBuf bytes.Buffer
|
||||
code := runCLI([]string{"Hallo Welt"}, &out, &errBuf)
|
||||
if code != exitOK {
|
||||
t.Fatalf(`talk "Hallo Welt" (sink=null): Exit-Code = %d, want %d; stderr = %q`, code, exitOK, errBuf.String())
|
||||
}
|
||||
if _, err := os.Stat(seen); err != nil {
|
||||
t.Errorf("Synthese lief nicht (fake espeak nicht gerufen): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaySubcommandWieDefault: `talk say "Text"` verhält sich wie der
|
||||
// Default-Pfad.
|
||||
func TestSaySubcommandWieDefault(t *testing.T) {
|
||||
seen := withFakeEspeak(t)
|
||||
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"})
|
||||
|
||||
var out, errBuf bytes.Buffer
|
||||
if code := runCLI([]string{"say", "Hallo"}, &out, &errBuf); code != exitOK {
|
||||
t.Fatalf("talk say: Exit-Code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
|
||||
}
|
||||
if _, err := os.Stat(seen); err != nil {
|
||||
t.Errorf("fake espeak nicht gerufen: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSayViaStdin: ohne Text-Argument wird stdin gelesen.
|
||||
func TestSayViaStdin(t *testing.T) {
|
||||
withFakeEspeak(t)
|
||||
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"})
|
||||
|
||||
var out, errBuf bytes.Buffer
|
||||
code := run([]string{}, strings.NewReader("Hallo Welt\n"), &out, &errBuf)
|
||||
if code != exitOK {
|
||||
t.Fatalf("talk < stdin: Exit-Code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaySprachwahl: englisches Argument übergibt -v en an den Provider,
|
||||
// deutsches -v de; --lang überschreibt die Erkennung.
|
||||
func TestSaySprachwahl(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
argv []string
|
||||
want string
|
||||
}{
|
||||
{"erkannt de", []string{"Der Build ist fertig"}, "-v de --stdout Der Build ist fertig"},
|
||||
{"erkannt en", []string{"The build is done"}, "-v en --stdout The build is done"},
|
||||
{"flag pinnt en", []string{"--lang=en", "Guten Morgen"}, "-v en --stdout Guten Morgen"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
seen := withFakeEspeak(t)
|
||||
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"})
|
||||
|
||||
var out, errBuf bytes.Buffer
|
||||
if code := runCLI(tc.argv, &out, &errBuf); code != exitOK {
|
||||
t.Fatalf("Exit-Code = %d, want %d; stderr = %q", code, exitOK, 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 != tc.want {
|
||||
t.Errorf("espeak ARGS = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSayOhneText: leeres stdin und kein Argument → klarer Fehler.
|
||||
func TestSayOhneText(t *testing.T) {
|
||||
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"})
|
||||
var out, errBuf bytes.Buffer
|
||||
code := runCLI(nil, &out, &errBuf)
|
||||
if code != exitFailure {
|
||||
t.Fatalf("talk ohne Text: Exit-Code = %d, want %d", code, exitFailure)
|
||||
}
|
||||
if !strings.Contains(errBuf.String(), "kein Text") {
|
||||
t.Errorf("stderr = %q, will klaren Hinweis auf fehlenden Text", errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSayUngueltigeSprache: --lang mit Unsinn ist ein klarer Fehler.
|
||||
func TestSayUngueltigeSprache(t *testing.T) {
|
||||
withFakeEspeak(t)
|
||||
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"})
|
||||
var out, errBuf bytes.Buffer
|
||||
code := runCLI([]string{"--lang=fr", "Bonjour"}, &out, &errBuf)
|
||||
if code != exitFailure {
|
||||
t.Fatalf("talk --lang=fr: Exit-Code = %d, want %d", code, exitFailure)
|
||||
}
|
||||
if !strings.Contains(errBuf.String(), "--lang") {
|
||||
t.Errorf("stderr = %q, will --lang-Fehler", errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSayUnbekannterProvider: --provider mit noch nicht implementiertem
|
||||
// Namen ist ein klarer Fehler (Task 03 ergänzt die 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)
|
||||
if code != exitFailure {
|
||||
t.Fatalf("talk --provider=elevenlabs: Exit-Code = %d, want %d", code, exitFailure)
|
||||
}
|
||||
if !strings.Contains(errBuf.String(), "elevenlabs") || !strings.Contains(errBuf.String(), "osbuiltin") {
|
||||
t.Errorf("stderr = %q, will Provider-Fehler mit verfügbaren Namen", errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSayUngueltigerSink: TALK_AUDIO_SINK=Müll ist ein klarer Fehler aus
|
||||
// der Config-Validierung.
|
||||
func TestSayUngueltigerSink(t *testing.T) {
|
||||
withFakeEspeak(t)
|
||||
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "mpv"})
|
||||
var out, errBuf bytes.Buffer
|
||||
code := runCLI([]string{"Test"}, &out, &errBuf)
|
||||
if code != exitFailure {
|
||||
t.Fatalf("talk mit TALK_AUDIO_SINK=mpv: Exit-Code = %d, want %d", code, exitFailure)
|
||||
}
|
||||
if !strings.Contains(errBuf.String(), "audio.sink") {
|
||||
t.Errorf("stderr = %q, will audio.sink-Validierungsfehler", errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSayOhneTTS: ohne jedes TTS-Binary ist Available false → klarer
|
||||
// Fehler, kein Panik.
|
||||
func TestSayOhneTTS(t *testing.T) {
|
||||
t.Setenv("PATH", t.TempDir())
|
||||
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"})
|
||||
var out, errBuf bytes.Buffer
|
||||
code := runCLI([]string{"Hallo"}, &out, &errBuf)
|
||||
if code != exitFailure {
|
||||
t.Fatalf("talk ohne TTS: Exit-Code = %d, want %d", code, exitFailure)
|
||||
}
|
||||
if !strings.Contains(errBuf.String(), "nicht verfügbar") {
|
||||
t.Errorf("stderr = %q, will Verfügbarkeits-Fehler", errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSayDirektesFallbeckenOhneEspeak: Linux ohne espeak (nur fake spd-say)
|
||||
// und sink=null verwirft tonlos mit Exit 0 — das spd-say wird NICHT gerufen
|
||||
// (null-Sink darf nie Ton erzeugen).
|
||||
func TestSayDirektesFallbeckenOhneEspeak(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
seen := filepath.Join(dir, "seen.txt")
|
||||
fakeTTSBin(t, dir, "spd-say", `printf '%s\n' "$*" > "`+seen+`"`)
|
||||
// PATH NUR auf das fake-spd-say-Verzeichnis: kein echtes espeak, kein
|
||||
// echtes spd-say dazwischen.
|
||||
t.Setenv("PATH", dir)
|
||||
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"})
|
||||
|
||||
var out, errBuf bytes.Buffer
|
||||
code := runCLI([]string{"Hallo Welt"}, &out, &errBuf)
|
||||
if code != exitOK {
|
||||
t.Fatalf("talk (Linux ohne espeak, sink=null): Exit-Code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
|
||||
}
|
||||
if _, err := os.Stat(seen); err == nil {
|
||||
t.Error("spd-say wurde gerufen — null-Sink hätte die Ansage verworfen müssen")
|
||||
}
|
||||
if !strings.Contains(errBuf.String(), "verworfen") {
|
||||
t.Errorf("stderr = %q, will Verwurf-Hinweis", errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSayDirektesFallbeckenMitOtoSink: ohne espeak, mit (fake) spd-say und
|
||||
// dem Default-Sink oto muss die Direktansage über spd-say laufen.
|
||||
func TestSayDirektesFallbeckenMitOtoSink(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
seen := filepath.Join(dir, "seen.txt")
|
||||
fakeTTSBin(t, dir, "spd-say", `printf '%s\n' "$*" > "`+seen+`"`)
|
||||
t.Setenv("PATH", dir)
|
||||
setGetenv(t, nil) // Default-Sink oto, keine Env-Overrides
|
||||
|
||||
var out, errBuf bytes.Buffer
|
||||
code := runCLI([]string{"Hallo Welt"}, &out, &errBuf)
|
||||
if code != exitOK {
|
||||
t.Fatalf("talk (Direktansage): Exit-Code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
|
||||
}
|
||||
args, err := os.ReadFile(seen)
|
||||
if err != nil {
|
||||
t.Fatalf("fake spd-say nicht gerufen: %v", err)
|
||||
}
|
||||
if got := strings.TrimSpace(string(args)); got != "-w -l de Hallo Welt" {
|
||||
t.Errorf("spd-say ARGS = %q, want %q", got, "-w -l de Hallo Welt")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user