# Review package: fa4f193..HEAD ## Commits 8306c6b docs(readme): say-Pfad als implementiert markieren, espeak als Dev-Abhängigkeit dokumentieren 8d42163 feat(cli): say-Pfad end-to-end — erste Ansage über OS-TTS 523e6f4 feat(audio): Audio-Sinks oto und null mit eigener WAV-Dekodierung 5f450ea feat(provider): Provider-Vertrag und osbuiltin für OS-eingebautes TTS 36ad1df feat(config): geschichtete Konfigurationsauflösung mit injizierbaren Pfaden de1ef64 feat(langdetect): deutsche/englische Spracherkennung als reine Funktion ## Files changed README.md | 22 +++- cmd/talk/cli.go | 103 +++++++++------ cmd/talk/cli_test.go | 54 ++++---- cmd/talk/main.go | 8 +- cmd/talk/say.go | 133 +++++++++++++++++++ cmd/talk/say_test.go | 247 ++++++++++++++++++++++++++++++++++++ go.mod | 11 ++ go.sum | 10 ++ internal/audio/audio.go | 55 ++++++++ internal/audio/audio_test.go | 177 ++++++++++++++++++++++++++ internal/audio/doc.go | 6 +- internal/audio/oto.go | 105 +++++++++++++++ internal/audio/wav.go | 81 ++++++++++++ internal/config/config.go | 94 ++++++++++++++ internal/config/config_test.go | 206 ++++++++++++++++++++++++++++++ internal/config/doc.go | 6 +- internal/config/path.go | 56 ++++++++ internal/langdetect/detect.go | 77 +++++++++++ internal/langdetect/detect_test.go | 37 ++++++ internal/langdetect/doc.go | 2 +- internal/provider/doc.go | 10 +- internal/provider/osbuiltin.go | 224 ++++++++++++++++++++++++++++++++ internal/provider/osbuiltin_test.go | 202 +++++++++++++++++++++++++++++ internal/provider/provider.go | 87 +++++++++++++ 24 files changed, 1925 insertions(+), 88 deletions(-) ## Diff README.md --- Text 28 Say-Flags (gelten auch für `talk "Te 28 Say-Flags (gelten auch für `talk "Te .. xt"`): `--wait`, `--now`, `--priorit .. xt"`): `--wait`, `--now`, `--priorit .. y`, .. y`, 29 `--voice`, `--lang`, `--provider`. 29 `--voice`, `--lang`, `--provider`. 30 30 31 **Hinweis:** Alle Befehle sind in di 31 **Hinweis:** In diesem Stand ist `sa .. esem Stand Stubs und melden .. y` (und der Default-Aufruf .. 32 `talk "Text"`) implementiert — die e .. .. rste Ansage über OS-TTS (blockierend .. .. ; .. 33 Daemon-Anbindung folgt). Alle andere .. .. n Befehle sind Stubs und melden 32 `not implemented yet` (Exit-Code ≠ 0 34 `not implemented yet` (Exit-Code ≠ 0 .. ). Die Implementierung entsteht .. ). .. 35 33 Aufgabenweise gemäß Plan; `--version 36 ### OS-TTS auf Linux (Dev-Abhängigke .. ` und `--help` funktionieren. .. it espeak) .. 37 .. 38 Der OS-Provider `osbuiltin` nutzt au .. .. f Linux zwei Wege: .. 39 .. 40 - **Audio-Bytes** (Wiedergabe über d .. .. en eingebauten oto-Sink): benötigt .. 41 `espeak` (oder `espeak-ng`). Auf N .. .. ixOS als Dev-Abhängigkeit: .. 42 `nix shell nixpkgs#espeak-ng`. .. 43 - **Direktansage** ohne espeak: `spd .. .. -say` (speech-dispatcher) — spielt .. 44 selbst über den Daemon ab, kann ab .. .. er nach `spd-say --help` keine Bytes .. 45 in Dateien/Pipes liefern. `talk` n .. .. utzt diesen Weg automatisch, wenn .. 46 kein espeak vorhanden ist. .. 47 .. 48 `TALK_AUDIO_SINK=null` (oder `audio. .. .. sink = "null"` in der config.toml) .. 49 schaltet jede Tongabe ab — für CI un .. .. d Server. 34 50 35 ## Entwicklung 51 ## Entwicklung 36 52 cmd/talk/cli.go --- 1/8 --- Go 17 // command ist ein Eintrag der Befe 17 // command ist ein Eintrag der Befe .. hlstabelle. Die Tabelle ist bewusst .. hlstabelle. Die Tabelle ist bewusst 18 // datengetrieben: Spätere Tasks (z 18 // datengetrieben: Spätere Tasks (z .. . B. die TUI-Befehle aus Task 09) e .. . B. die TUI-Befehle aus Task 09) e .. rgänzen .. rgänzen 19 // hier Einträge bzw. Register-Funk 19 // hier Einträge bzw. Register-Funk .. tionen, ohne die Dispatch-Logik in .. tionen, ohne die Dispatch-Logik in .. run() .. run() 20 // anzufassen. 20 // anzufassen. register hängt die F .. .. lags des Befehls an den FlagSet und .. .. liefert .. 21 // die gebundenen say-Options (derz .. .. eit der einzige Befehl mit eigenen .. .. Flags; .. 22 // erhält ein weiterer Befehl eigen .. .. e Flags, wird die Signatur verallge .. .. meinert). 21 type command struct { 23 type command struct { 22 name string 24 name string .. // Subcommand-Name (bindende CLI-S .. .. urface aus der Spec) .. // Subcommand-Name .. .. (bindende CLI-Surface aus der Spec .. .. ) 23 short string 25 short string .. // Kurzbeschreibung für `talk --he .. .. lp` .. // Kurzbeschreibun .. .. g für `talk --help` 24 args string 26 args string .. // Positionsargumente-Hinweis für .. .. die Usage-Zeile .. // Positionsargume .. .. nte-Hinweis für die Usage-Zeile 25 detail string 27 detail string .. // Beschreibung für `talk .. .. --help` .. // Beschreibung fü .. .. r `talk --help` 26 register func(fs *flag.FlagSet) 28 register func(fs *flag.FlagSet) .. // hängt die Flags des Befehls an .. *sayOptions .. den FlagSet .. // hängt die Flags .. .. des Befehls an den FlagSet .. 29 handler func(args []string, o .. .. *sayOptions, stdin io.Reader, stder .. .. r io.Writer) int // Ausführung (feh .. .. lt sie: Stub) 27 } 30 } 28 31 29 // commands ist die vollständige Be 32 // commands ist die vollständige Be .. fehlstabelle. Die Namen sind Teil d .. fehlstabelle. Die Namen sind Teil d .. er .. er cmd/talk/cli.go --- 2/8 --- Go 35 38 args: `"Text"`, 36 39 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\"`.", 37 40 register: addSayFlags, .. 41 handler: runSay, 38 42 }, 39 43 { 40 44 name: "wrap", cmd/talk/cli.go --- 3/8 --- Go 84 }, 88 }, 85 } 89 } 86 90 87 // addSayFlags registriert die Flag 91 // addSayFlags registriert die Flag .. s des say-Befehls. Sie gelten auch .. s des say-Befehls und bindet sie an .. für den .. die .. 92 // zurückgegebene Options-Struct. S .. .. ie gelten auch für den Default-Pfad 88 // Default-Pfad `talk "Text"` (glei 93 // `talk "Text"` (gleiche Flag-Ober .. che Flag-Oberfläche, festgelegt in .. fläche, festgelegt in der Spec). Da .. der .. s 89 // Spec). Das Verhalten entsteht in 94 // Verhalten: direkter Pfad in Task .. Task 02 (direkter Pfad) bzw. Task .. 02, Daemon-Pfad in Task 04. .. 04 .. .. 95 // .. 96 // Rückgabe ist bewusst ein Pointer .. .. : flag.Var bindet an die Felder gen .. .. au .. 97 // dieser Struct — eine Wertkopie h .. .. ätte nach dem Parse weiterhin die 90 // (Daemon-Pfad). 98 // Defaults. 91 func addSayFlags(fs *flag.FlagSet) 99 func addSayFlags(fs *flag.FlagSet) .. { .. *sayOptions { .. 100 o := &sayOptions{} 92 fs.Bool("wait", false, "warten, 101 fs.BoolVar(&o.wait, "wait", fal .. bis die Ansage fertig abgespielt i ... se, "warten, bis die Ansage fertig .. st") ... abgespielt ist") 93 fs.Bool("now", false, "Ansage s 102 fs.BoolVar(&o.now, "now", false .. ofort ausgeben (springt die Wartesc ... , "Ansage sofort ausgeben (springt .. hlange)") ... die Warteschlange)") 94 fs.String("priority", "normal", 103 fs.StringVar(&o.priority, "prio .. "Priorität: low | normal | high") ... rity", "normal", "Priorität: low | .. ... normal | high") 95 fs.String("voice", "", "Stimme 104 fs.StringVar(&o.voice, "voice", .. als provider-spezifische ID") ... "", "Stimme als provider-spezifisc .. ... he ID") 96 fs.String("lang", "", "Sprache: 105 fs.StringVar(&o.lang, "lang", " .. de | en (Default: automatische Erk ... ", "Sprache: de | en (Default: auto .. ennung)") ... matische Erkennung)") 97 fs.String("provider", "", "Prov 106 fs.StringVar(&o.provider, "prov .. ider pinnen: elevenlabs | openai | ... ider", "", "Provider pinnen: eleven .. edge | osbuiltin") ... labs | openai | edge | osbuiltin") .. 107 return o 98 } 108 } 99 109 100 // run ist der Einstiegspunkt der C 110 // run ist der Einstiegspunkt der C ... LI (Argumente ohne Programmnamen). ... LI (Argumente ohne Programmnamen). ... Alle ... Alle 101 // Ausgaben gehen auf die übergeben 111 // Ausgaben gehen auf die übergeben ... en Writer, damit run direkt und ohn ... en Writer, stdin ist als Reader inj ... e ... iziert ... 112 // — damit ist run direkt und ohne ... ... Prozessstart testbar (auch der stdi ... ... n- 102 // Prozessstart testbar ist. Rückga 113 // Pfad aus Task 02). Rückgabe ist ... be ist der Exit-Code. ... der Exit-Code. 103 func run(argv []string, stdout, std 114 func run(argv []string, stdin io.Re ... err io.Writer) int { ... ader, stdout, stderr io.Writer) int ... ... { 104 if len(argv) == 0 { 115 if len(argv) == 0 { 105 // Task 02 macht aus dem na 116 // Kein Befehl und kein Arg ... ckten Aufruf ggf. einen stdin-Leser ... ument: Der Text könnte über stdin 106 // (Text kommt dann über di 117 // kommen (Pipe). An einem ... e Pipe); bis dahin: Benutzungsfehle ... Terminal gibt es keinen Text — dann ... r. ... ... 118 // Benutzungsfehler, damit ... ... talk nie auf Eingaben wartet. ... 119 if isCharDevice(stdin) { 107 writeUsage(stderr) 120 writeUsage(stderr) 108 return exitUsage 121 return exitUsage ... 122 } ... 123 return runSay(nil, &sayOpti ... ... ons{}, stdin, stderr) 109 } 124 } 110 125 111 if argv[0] == "help" { 126 if argv[0] == "help" { cmd/talk/cli.go --- 4/8 --- Go 122 } 137 } 123 138 124 if cmd, ok := lookupCommand(arg 139 if cmd, ok := lookupCommand(arg ... v[0]); ok { ... v[0]); ok { 125 return runCommand(cmd, argv 140 return runCommand(cmd, argv ... [1:], stdout, stderr) ... [1:], stdin, stdout, stderr) 126 } 141 } 127 142 128 // Kein bekannter Befehl: Defau 143 // Kein bekannter Befehl: Defau ... lt-Pfad `talk "Text"` (mappt auf sa ... lt-Pfad `talk "Text"` (mappt auf sa ... y) ... y) 129 // bzw. Root-Flags (--version, 144 // bzw. Root-Flags (--version, ... --help). Führt aktuell auf den say- ... --help). ... Stub. ... 130 return runDefault(argv, stdout, 145 return runDefault(argv, stdin, ... stderr) ... stdout, stderr) 131 } 146 } 132 147 133 func lookupCommand(name string) (co 148 func lookupCommand(name string) (co ... mmand, bool) { ... mmand, bool) { cmd/talk/cli.go --- 5/8 --- Go 140 } 155 } 141 156 142 // newCommandFlagSet baut den FlagS 157 // newCommandFlagSet baut den FlagS ... et eines Befehls: eigene Flags plus ... et eines Befehls: eigene Flags plus ... ein ... ein 143 // explizites --help. Die Default-U 158 // explizites --help und die gebund ... sage wird stummgeschaltet, damit Hi ... enen say-Options. Die Default-Usage ... lfetext ... wird ... 159 // stummgeschaltet, damit Hilfetext ... ... ausschließlich über writeCommandHe ... ... lp 144 // ausschließlich über writeCommand 160 // (eine Quelle) ausgegeben wird. ... Help (eine Quelle) ausgegeben wird. ... 145 func newCommandFlagSet(cmd command, 161 func newCommandFlagSet(cmd command, ... out io.Writer) (*flag.FlagSet, *bo ... out io.Writer) (*flag.FlagSet, *bo ... ol) { ... ol, *sayOptions) { 146 fs := flag.NewFlagSet("talk "+c 162 fs := flag.NewFlagSet("talk "+c ... md.name, flag.ContinueOnError) ... md.name, flag.ContinueOnError) 147 fs.SetOutput(out) 163 fs.SetOutput(out) 148 fs.Usage = func() {} 164 fs.Usage = func() {} ... 165 var sayOpts *sayOptions 149 if cmd.register != nil { 166 if cmd.register != nil { 150 cmd.register(fs) 167 sayOpts = cmd.register(fs) 151 } 168 } 152 help := fs.Bool("help", false, 169 help := fs.Bool("help", false, ... "Hilfe zu diesem Befehl anzeigen") ... "Hilfe zu diesem Befehl anzeigen") 153 return fs, help 170 return fs, help, sayOpts 154 } 171 } 155 172 156 // runCommand führt einen benannten 173 // runCommand führt einen benannten ... Subcommand aus — in diesem Stand a ... Subcommand aus. say ist implementi ... ls Stub. ... ert ... 174 // (Task 02); alle anderen sind Stu ... ... bs bis zu ihren Tasks. 157 func runCommand(cmd command, args [ 175 func runCommand(cmd command, args [ ... ]string, stdout, stderr io.Writer) ... ]string, stdin io.Reader, stdout, s ... int { ... tderr io.Writer) int { 158 fs, help := newCommandFlagSet(c 176 fs, help, sayOpts := newCommand ... md, stderr) ... FlagSet(cmd, stderr) 159 if err := fs.Parse(args); err ! 177 if err := fs.Parse(args); err ! ... = nil { ... = nil { 160 if errors.Is(err, flag.ErrH 178 if errors.Is(err, flag.ErrH ... elp) { // z. B. nacktes -h ... elp) { // z. B. nacktes -h 161 writeCommandHelp(cmd, s 179 writeCommandHelp(cmd, s ... tdout) ... tdout) cmd/talk/cli.go --- 6/8 --- Go 169 187 writeCommandHelp(cmd, stdout) 170 188 return exitOK 171 189 } ... 190 if cmd.handler != nil { ... 191 return cmd.handler(fs.Args(), sayOpts, stdin, stderr) ... 192 } 172 193 return notImplemented(cmd.name, stderr) 173 194 } 174 195 175 196 // runDefault behandelt Aufrufe ohne bekannten Subcommand: `talk "Text"`, 176 197 // `talk --version`, `talk --help` sowie say-Flags vor dem Text. 177 198 func runDefault(argv []string, stdin io.Reader, stdout, stderr io.Writer) int { 178 199 fs := flag.NewFlagSet("talk", flag.ContinueOnError) 179 200 fs.SetOutput(stderr) 180 201 fs.Usage = func() {} 181 202 showVersion := fs.Bool("version", false, "Version anzeigen") 182 203 help := fs.Bool("help", false, "diese Hilfe anzeigen") 183 204 sayOpts := addSayFlags(fs) 184 205 185 206 if err := fs.Parse(argv); err != nil { 186 207 if errors.Is(err, flag.ErrHelp) { cmd/talk/cli.go --- 7/8 --- Go 198 writeUsage(stdout) 219 writeUsage(stdout) 199 return exitOK 220 return exitOK 200 } 221 } 201 return notImplemented("say", st 222 return runSay(fs.Args(), sayOpt ... derr) ... s, stdin, stderr) 202 } 223 } 203 224 204 // versionString rendert die Versio 225 // versionString rendert die Versio ... nsausgabe: `talk version ` ... nsausgabe: `talk version ` ... plus ... plus cmd/talk/cli.go --- 8/8 --- Go 253 274 fmt.Fprintf(w, "%s\n\n", cmd.detail) 254 275 } 255 276 fmt.Fprintln(w, "Flags:") 256 277 fs, _, _ := newCommandFlagSet(cmd, w) 257 278 fs.PrintDefaults() ... 279 if cmd.handler == nil { 258 280 fmt.Fprintln(w, "\nStatus: noch nicht implementiert (WIP).") ... 281 } 259 282 } cmd/talk/cli_test.go --- 1/8 --- Go 2 2 3 3 import ( 4 4 "bytes" . 5 "io" 5 6 "strings" 6 7 "testing" 7 8 ) cmd/talk/cli_test.go --- 2/8 --- Go 13 "say", "wrap", "mute", "unmute" 14 "say", "wrap", "mute", "unmute" .. , "status", "cancel", "voices", "te .. , "status", "cancel", "voices", "te .. st", "doctor", "setup", .. st", "doctor", "setup", 14 } 15 } .. 16 .. 17 // wantStubCommands ist wantSubcomm .. .. ands ohne say — say ist seit Task 0 .. .. 2 .. 18 // implementiert, alle anderen blei .. .. ben Stubs bis zu ihren Tasks. .. 19 var wantStubCommands = []string{ .. 20 "wrap", "mute", "unmute", "stat .. .. us", "cancel", "voices", "test", "d .. .. octor", "setup", .. 21 } .. 22 .. 23 // runCLI ist der Test-Einstieg in .. .. run mit leerem stdin (kein Text übe .. .. r .. 24 // Pipe) — dem üblichen Aufruf ohne .. .. Eingabe. .. 25 func runCLI(argv []string, stdout, .. .. stderr io.Writer) int { .. 26 return run(argv, strings.NewRea .. .. der(""), stdout, stderr) .. 27 } 15 28 16 func TestHelpListetAlleSubcommands( 29 func TestHelpListetAlleSubcommands( .. t *testing.T) { .. t *testing.T) { 17 var out, errBuf bytes.Buffer 30 var out, errBuf bytes.Buffer 18 code := run([]string{"--help"}, 31 code := runCLI([]string{"--help .. &out, &errBuf) .. "}, &out, &errBuf) 19 if code != exitOK { 32 if code != exitOK { 20 t.Fatalf("--help: Exit-Code 33 t.Fatalf("--help: Exit-Code .. = %d, want %d (stderr: %q)", code, .. = %d, want %d (stderr: %q)", code, .. exitOK, errBuf.String()) .. exitOK, errBuf.String()) 21 } 34 } cmd/talk/cli_test.go --- 3/8 --- Go 36 49 37 func TestVersionDefault(t *testing. 50 func TestVersionDefault(t *testing. .. T) { .. T) { 38 var out, errBuf bytes.Buffer 51 var out, errBuf bytes.Buffer 39 code := run([]string{"--version 52 code := runCLI([]string{"--vers .. "}, &out, &errBuf) .. ion"}, &out, &errBuf) 40 if code != exitOK { 53 if code != exitOK { 41 t.Fatalf("--version: Exit-C 54 t.Fatalf("--version: Exit-C .. ode = %d, want %d", code, exitOK) .. ode = %d, want %d", code, exitOK) 42 } 55 } cmd/talk/cli_test.go --- 4/8 --- Go 56 } 69 } 57 70 58 func TestSubcommandStubs(t *testing 71 func TestSubcommandStubs(t *testing .. .T) { .. .T) { 59 for _, name := range wantSubcom 72 for _, name := range wantStubCo .. mands { .. mmands { 60 t.Run(name, func(t *testing 73 t.Run(name, func(t *testing .. .T) { .. .T) { 61 var out, errBuf bytes.B 74 var out, errBuf bytes.B .. uffer .. uffer 62 code := run([]string{na 75 code := runCLI([]string .. me}, &out, &errBuf) .. {name}, &out, &errBuf) 63 if code == exitOK { 76 if code == exitOK { 64 t.Errorf("talk %s: 77 t.Errorf("talk %s: .. Exit-Code 0, want non-zero für Stub .. Exit-Code 0, want non-zero für Stub .. ", name) .. ", name) 65 } 78 } cmd/talk/cli_test.go --- 5/8 --- Go 76 } 89 } 77 } 90 } 78 .. 79 func TestDefaultAufrufMapptAufSay(t .. .. *testing.T) { .. 80 var out, errBuf bytes.Buffer .. 81 code := run([]string{"Hallo Wel .. .. t"}, &out, &errBuf) .. 82 if code != exitFailure { .. 83 t.Fatalf(`talk "Hallo Welt" .. .. : Exit-Code = %d, want %d`, code, e .. .. xitFailure) .. 84 } .. 85 if !strings.Contains(errBuf.Str .. .. ing(), "talk say is not implemented .. .. yet") { .. 86 t.Errorf("Default-Aufruf ma .. .. ppt offenbar nicht auf say, stderr .. .. = %q", errBuf.String()) .. 87 } .. 88 } .. 89 91 90 func TestSayHelpZeigtFlags(t *testi 92 func TestSayHelpZeigtFlags(t *testi .. ng.T) { .. ng.T) { 91 var out, errBuf bytes.Buffer 93 var out, errBuf bytes.Buffer 92 code := run([]string{"say", "-- 94 code := runCLI([]string{"say", .. help"}, &out, &errBuf) .. "--help"}, &out, &errBuf) 93 if code != exitOK { 95 if code != exitOK { 94 t.Fatalf("talk say --help: 96 t.Fatalf("talk say --help: .. Exit-Code = %d, want %d", code, exi .. Exit-Code = %d, want %d", code, exi .. tOK) .. tOK) 95 } 97 } cmd/talk/cli_test.go --- 6/8 --- Go 98 t.Errorf("talk say --he 100 t.Errorf("talk say --he .. lp zeigt Flag %s nicht (Output: %q) ... lp zeigt Flag %s nicht (Output: %q) .. ", f, out.String()) ... ", f, out.String()) 99 } 101 } 100 } 102 } ... 103 if strings.Contains(out.String( ... ... ), "noch nicht implementiert") { ... 104 t.Errorf("talk say --help s ... ... oll say nicht mehr als Stub markier ... ... en: %q", out.String()) ... 105 } 101 } 106 } 102 107 103 func TestHelpSubcommand(t *testing. 108 func TestHelpSubcommand(t *testing. ... T) { ... T) { 104 t.Run("ohne Argument", func(t * 109 t.Run("ohne Argument", func(t * ... testing.T) { ... testing.T) { 105 var out, errBuf bytes.Buffe 110 var out, errBuf bytes.Buffe ... r ... r 106 if code := run([]string{"he 111 if code := runCLI([]string{ ... lp"}, &out, &errBuf); code != exitO ... "help"}, &out, &errBuf); code != ex ... K { ... itOK { 107 t.Fatalf("talk help: Ex 112 t.Fatalf("talk help: Ex ... it-Code = %d, want %d", code, exitO ... it-Code = %d, want %d", code, exitO ... K) ... K) 108 } 113 } 109 for _, name := range wantSu 114 for _, name := range wantSu ... bcommands { ... bcommands { cmd/talk/cli_test.go --- 7/8 --- Go 114 }) 119 }) 115 t.Run("mit Befehl", func(t *tes 120 t.Run("mit Befehl", func(t *tes ... ting.T) { ... ting.T) { 116 var out, errBuf bytes.Buffe 121 var out, errBuf bytes.Buffe ... r ... r 117 if code := run([]string{"he 122 if code := runCLI([]string{ ... lp", "mute"}, &out, &errBuf); code ... "help", "mute"}, &out, &errBuf); co ... != exitOK { ... de != exitOK { 118 t.Fatalf("talk help mut 123 t.Fatalf("talk help mut ... e: Exit-Code = %d, want %d", code, ... e: Exit-Code = %d, want %d", code, ... exitOK) ... exitOK) 119 } 124 } 120 if !strings.Contains(out.St 125 if !strings.Contains(out.St ... ring(), "talk mute") { ... ring(), "talk mute") { cmd/talk/cli_test.go --- 8/8 --- Go 123 }) 128 }) 124 } 129 } 125 ... 126 func TestOhneArgumenteZeigtUsage(t ... ... *testing.T) { ... 127 var out, errBuf bytes.Buffer ... 128 code := run(nil, &out, &errBuf) ... 129 if code != exitUsage { ... 130 t.Fatalf("talk ohne Argumen ... ... te: Exit-Code = %d, want %d", code, ... ... exitUsage) ... 131 } ... 132 if !strings.Contains(errBuf.Str ... ... ing(), "Usage") { ... 133 t.Errorf("talk ohne Argumen ... ... te: Usage fehlt auf stderr: %q", er ... ... rBuf.String()) ... 134 } ... 135 } ... 136 130 137 func TestUnbekanntesFlag(t *testing 131 func TestUnbekanntesFlag(t *testing ... .T) { ... .T) { 138 var out, errBuf bytes.Buffer 132 var out, errBuf bytes.Buffer 139 code := run([]string{"--bogus"} 133 code := runCLI([]string{"--bogu ... , &out, &errBuf) ... s"}, &out, &errBuf) 140 if code != exitUsage { 134 if code != exitUsage { 141 t.Fatalf("talk --bogus: Exi 135 t.Fatalf("talk --bogus: Exi ... t-Code = %d, want %d", code, exitUs ... t-Code = %d, want %d", code, exitUs ... age) ... age) 142 } 136 } cmd/talk/main.go --- 1/2 --- Go 1 // talk ist die CLI für Sprach-Ausga 1 // talk ist die CLI für Sprach-Ausga . ben (talk 2.0). . ben (talk 2.0). 2 // 2 // 3 // Dieser Stand (Task 01) liefert da 3 // Dieser Stand (Task 02) liefert de . s Gerüst: Subcommand-Dispatch, --ver . n say-Pfad end-to-end: Text aus . sion . 4 // und alle Befehle als Stubs. Die F 4 // Argument oder stdin → Spracherken . unktionalität entsteht in den Tasks . nung → Provider (osbuiltin) → . 02–09. . . 5 // Wiedergabe über einen Audio-Sink . . (oto/null). Die übrigen Befehle sind . 6 // Stubs; Daemon, Kette und TUI folg . . en in den Tasks 03–09. 5 package main 7 package main 6 8 7 import "os" 9 import "os" cmd/talk/main.go --- 2/2 --- Go 17 19 ) 18 20 19 21 func main() { 20 22 os.Exit(run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) 21 23 } cmd/talk/say.go --- Go 1 package main 2 3 import ( 4 "context" 5 "errors" 6 "fmt" 7 "io" 8 "os" 9 "strings" 10 11 "code.m3ta.dev/m3tam3re/talk/internal/audio" 12 "code.m3ta.dev/m3tam3re/talk/internal/config" 13 "code.m3ta.dev/m3tam3re/talk/internal/langdetect" 14 "code.m3ta.dev/m3tam3re/talk/internal/provider" 15 ) 16 17 // getenv ist die Env-Seam von run (Tests injizieren hier TALK_AUDIO_SINK 18 // etc., ohne den Prozess-Environment anzufassen). Default: os.Getenv. 19 var getenv = os.Getenv 20 21 // sayOptions bündelt die Flags von say (identisch im Default-Pfad 22 // `talk "Text"`). 23 type sayOptions struct { 24 wait bool 25 now bool 26 priority string 27 voice string 28 lang string 29 provider string 30 } 31 32 // runSay ist der komplette Ansage-Pfad von Task 02 (direkt und blockierend; 33 // Daemon-Anbindung folgt in Task 04): Text aus Argumenten oder stdin → 34 // Spracherkennung → Provider (Default osbuiltin; Kette folgt Task 03) → 35 // Synthesize → Sink.Play. Auf Plattformen, deren OS-TTS keine Bytes liefern 36 // kann (Linux ohne espeak), springt die dokumentierte Erweiterung 37 // provider.DirectSpeaker an (spd-say); mit audio.sink=null wird die 38 // Ansage stattdessen verworfen — CI/Server bleiben ton- und fehlerfrei. 39 func runSay(args []string, opts *sayOptions, stdin io.Reader, stderr io.Writer) int { 40 text := strings.Join(args, " ") 41 if text == "" { 42 if isCharDevice(stdin) { 43 fmt.Fprintln(stderr, "talk: kein Text — bitte als Argument übergeben oder über stdin pipen") 44 return exitUsage 45 } 46 b, err := io.ReadAll(stdin) 47 if err != nil { 48 fmt.Fprintf(stderr, "talk: stdin lesen: %v\n", err) 49 return exitFailure 50 } 51 text = strings.TrimSpace(string(b)) 52 if text == "" { 53 fmt.Fprintln(stderr, "talk: kein Text (Argument und stdin sind leer)") 54 return exitFailure 55 } 56 } 57 58 lang := opts.lang 59 if lang == "" { 60 lang = langdetect.Detect(text) 61 } 62 if lang != "de" && lang != "en" { 63 fmt.Fprintf(stderr, "talk: --lang: ungültiger Wert %q (erlaubt: de, en)\n", lang) 64 return exitFailure 65 } 66 if opts.voice != "" { 67 fmt.Fprintln(stderr, "talk: Hinweis: --voice wirkt ab Task 03 (Voice-Registry) und wird für diese Ansage ignoriert") 68 } 69 70 cfg, err := config.Load(config.Options{Getenv: getenv}) 71 if err != nil { 72 fmt.Fprintf(stderr, "talk: %v\n", err) 73 return exitFailure 74 } 75 76 p, err := provider.New(opts.provider) 77 if err != nil { 78 fmt.Fprintf(stderr, "talk: %v\n", err) 79 return exitFailure 80 } 81 82 ctx := context.Background() 83 if !p.Available(ctx) { 84 fmt.Fprintf(stderr, "talk: Provider %s ist auf diesem System nicht verfügbar (keine passende OS-TTS-Runtime gefunden)\n", p.Name()) 85 return exitFailure 86 } 87 88 data, err := p.Synthesize(ctx, text, lang) 89 if err != nil { 90 if errors.Is(err, provider.ErrNoByteOutput) { 91 if cfg.Audio.Sink == config.SinkNull { 92 // null-Sink verwirft die Ansage grundsätzlich — auch die 93 // Direktansage würde Ton erzeugen und entfällt somit. 94 fmt.Fprintln(stderr, "talk: Ansage verworfen (audio.sink=null)") 95 return exitOK 96 } 97 if ds, ok := p.(provider.DirectSpeaker); ok { 98 if err := ds.SpeakDirect(ctx, text, lang); err != nil { 99 fmt.Fprintf(stderr, "talk: %v\n", err) 100 return exitFailure 101 } 102 return exitOK 103 } 104 } 105 fmt.Fprintf(stderr, "talk: %v\n", err) 106 return exitFailure 107 } 108 109 sink, err := audio.NewSink(cfg.Audio.Sink) 110 if err != nil { 111 fmt.Fprintf(stderr, "talk: %v\n", err) 112 return exitFailure 113 } 114 if err := sink.Play(ctx, data); err != nil { 115 fmt.Fprintf(stderr, "talk: Wiedergabe über %s fehlgeschlagen: %v\n", sink.Name(), err) 116 return exitFailure 117 } 118 return exitOK 119 } 120 121 // isCharDevice meldet, ob r ein Terminal ist (dann darf talk nicht auf 122 // stdin-Input warten, sondern meldet einen Benutzungsfehler). 123 func isCharDevice(r io.Reader) bool { 124 f, ok := r.(*os.File) 125 if !ok { 126 return false 127 } 128 st, err := f.Stat() 129 if err != nil { 130 return false 131 } 132 return st.Mode()&os.ModeCharDevice != 0 133 } 134 cmd/talk/say_test.go --- Go 1 package main 2 3 import ( 4 "bytes" 5 "os" 6 "path/filepath" 7 "strings" 8 "testing" 9 ) 10 11 // setGetenv überschreibt die Env-Seam der CLI für einen Test. 12 func setGetenv(t *testing.T, kv map[string]string) { 13 t.Helper() 14 old := getenv 15 getenv = func(k string) string { 16 if v, ok := kv[k]; ok { 17 return v 18 } 19 return "" 20 } 21 t.Cleanup(func() { getenv = old }) 22 } 23 24 // fakeTTSBin schreibt ein ausführbares Shell-Skript namens name in dir 25 // (gleicher Mechanismus wie im provider-Paket: echter Prozess, PATH- 26 // Manipulation, kein Mock-Framework). 27 func fakeTTSBin(t *testing.T, dir, name, body string) { 28 t.Helper() 29 script := "#!/bin/sh\n" + body + "\n" 30 path := filepath.Join(dir, name) 31 if err := os.WriteFile(path, []byte(script), 0o755); err != nil { 32 t.Fatal(err) 33 } 34 } 35 36 // withFakeEspeak stellt ein fake espeak bereit, das WAV-Bytes auf stdout 37 // schreibt und den Aufruf in seen protokolliert. Zurück kommt der Pfad der 38 // Protokolldatei. 39 func withFakeEspeak(t *testing.T) string { 40 t.Helper() 41 dir := t.TempDir() 42 wav := filepath.Join(dir, "probe.wav") 43 if err := os.WriteFile(wav, []byte("RIFFxxxxWAVEfmt "), 0o644); err != nil { 44 t.Fatal(err) 45 } 46 seen := filepath.Join(dir, "seen.txt") 47 fakeTTSBin(t, dir, "espeak", `printf '%s\n' "$*" > "`+seen+`" 48 cat "`+wav+`"`) 49 t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) 50 return seen 51 } 52 53 // TestSayArgumentMitNullSink: `talk "Hallo Welt"` läuft mit 54 // TALK_AUDIO_SINK=null tonlos durch (Abnahmekriterium) — Synthese läuft 55 // (fake espeak wurde gerufen), die Wiedergabe wird verworfen. 56 func TestSayArgumentMitNullSink(t *testing.T) { 57 seen := withFakeEspeak(t) 58 setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"}) 59 60 var out, errBuf bytes.Buffer 61 code := runCLI([]string{"Hallo Welt"}, &out, &errBuf) 62 if code != exitOK { 63 t.Fatalf(`talk "Hallo Welt" (sink=null): Exit-Code = %d, want %d; stderr = %q`, code, exitOK, errBuf.String()) 64 } 65 if _, err := os.Stat(seen); err != nil { 66 t.Errorf("Synthese lief nicht (fake espeak nicht gerufen): %v", err) 67 } 68 } 69 70 // TestSaySubcommandWieDefault: `talk say "Text"` verhält sich wie der 71 // Default-Pfad. 72 func TestSaySubcommandWieDefault(t *testing.T) { 73 seen := withFakeEspeak(t) 74 setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"}) 75 76 var out, errBuf bytes.Buffer 77 if code := runCLI([]string{"say", "Hallo"}, &out, &errBuf); code != exitOK { 78 t.Fatalf("talk say: Exit-Code = %d, want %d; stderr = %q", code, exitOK, errBuf.String()) 79 } 80 if _, err := os.Stat(seen); err != nil { 81 t.Errorf("fake espeak nicht gerufen: %v", err) 82 } 83 } 84 85 // TestSayViaStdin: ohne Text-Argument wird stdin gelesen. 86 func TestSayViaStdin(t *testing.T) { 87 withFakeEspeak(t) 88 setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"}) 89 90 var out, errBuf bytes.Buffer 91 code := run([]string{}, strings.NewReader("Hallo Welt\n"), &out, &errBuf) 92 if code != exitOK { 93 t.Fatalf("talk < stdin: Exit-Code = %d, want %d; stderr = %q", code, exitOK, errBuf.String()) 94 } 95 } 96 97 // TestSaySprachwahl: englisches Argument übergibt -v en an den Provider, 98 // deutsches -v de; --lang überschreibt die Erkennung. 99 func TestSaySprachwahl(t *testing.T) { 100 cases := []struct { 101 name string 102 argv []string 103 want string 104 }{ 105 {"erkannt de", []string{"Der Build ist fertig"}, "-v de --stdout Der Build ist fertig"}, 106 {"erkannt en", []string{"The build is done"}, "-v en --stdout The build is done"}, 107 {"flag pinnt en", []string{"--lang=en", "Guten Morgen"}, "-v en --stdout Guten Morgen"}, 108 } 109 for _, tc := range cases { 110 t.Run(tc.name, func(t *testing.T) { 111 seen := withFakeEspeak(t) 112 setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"}) 113 114 var out, errBuf bytes.Buffer 115 if code := runCLI(tc.argv, &out, &errBuf); code != exitOK { 116 t.Fatalf("Exit-Code = %d, want %d; stderr = %q", code, exitOK, errBuf.String()) 117 } 118 args, err := os.ReadFile(seen) 119 if err != nil { 120 t.Fatalf("fake espeak nicht gerufen: %v", err) 121 } 122 if got := strings.TrimSpace(string(args)); got != tc.want { 123 t.Errorf("espeak ARGS = %q, want %q", got, tc.want) 124 } 125 }) 126 } 127 } 128 129 // TestSayOhneText: leeres stdin und kein Argument → klarer Fehler. 130 func TestSayOhneText(t *testing.T) { 131 setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"}) 132 var out, errBuf bytes.Buffer 133 code := runCLI(nil, &out, &errBuf) 134 if code != exitFailure { 135 t.Fatalf("talk ohne Text: Exit-Code = %d, want %d", code, exitFailure) 136 } 137 if !strings.Contains(errBuf.String(), "kein Text") { 138 t.Errorf("stderr = %q, will klaren Hinweis auf fehlenden Text", errBuf.String()) 139 } 140 } 141 142 // TestSayUngueltigeSprache: --lang mit Unsinn ist ein klarer Fehler. 143 func TestSayUngueltigeSprache(t *testing.T) { 144 withFakeEspeak(t) 145 setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"}) 146 var out, errBuf bytes.Buffer 147 code := runCLI([]string{"--lang=fr", "Bonjour"}, &out, &errBuf) 148 if code != exitFailure { 149 t.Fatalf("talk --lang=fr: Exit-Code = %d, want %d", code, exitFailure) 150 } 151 if !strings.Contains(errBuf.String(), "--lang") { 152 t.Errorf("stderr = %q, will --lang-Fehler", errBuf.String()) 153 } 154 } 155 156 // TestSayUnbekannterProvider: --provider mit noch nicht implementiertem 157 // Namen ist ein klarer Fehler (Task 03 ergänzt die Kette). 158 func TestSayUnbekannterProvider(t *testing.T) { 159 withFakeEspeak(t) 160 setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"}) 161 var out, errBuf bytes.Buffer 162 code := runCLI([]string{"--provider=elevenlabs", "Test"}, &out, &errBuf) 163 if code != exitFailure { 164 t.Fatalf("talk --provider=elevenlabs: Exit-Code = %d, want %d", code, exitFailure) 165 } 166 if !strings.Contains(errBuf.String(), "elevenlabs") || !strings.Contains(errBuf.String(), "osbuiltin") { 167 t.Errorf("stderr = %q, will Provider-Fehler mit verfügbaren Namen", errBuf.String()) 168 } 169 } 170 171 // TestSayUngueltigerSink: TALK_AUDIO_SINK=Müll ist ein klarer Fehler aus 172 // der Config-Validierung. 173 func TestSayUngueltigerSink(t *testing.T) { 174 withFakeEspeak(t) 175 setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "mpv"}) 176 var out, errBuf bytes.Buffer 177 code := runCLI([]string{"Test"}, &out, &errBuf) 178 if code != exitFailure { 179 t.Fatalf("talk mit TALK_AUDIO_SINK=mpv: Exit-Code = %d, want %d", code, exitFailure) 180 } 181 if !strings.Contains(errBuf.String(), "audio.sink") { 182 t.Errorf("stderr = %q, will audio.sink-Validierungsfehler", errBuf.String()) 183 } 184 } 185 186 // TestSayOhneTTS: ohne jedes TTS-Binary ist Available false → klarer 187 // Fehler, kein Panik. 188 func TestSayOhneTTS(t *testing.T) { 189 t.Setenv("PATH", t.TempDir()) 190 setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"}) 191 var out, errBuf bytes.Buffer 192 code := runCLI([]string{"Hallo"}, &out, &errBuf) 193 if code != exitFailure { 194 t.Fatalf("talk ohne TTS: Exit-Code = %d, want %d", code, exitFailure) 195 } 196 if !strings.Contains(errBuf.String(), "nicht verfügbar") { 197 t.Errorf("stderr = %q, will Verfügbarkeits-Fehler", errBuf.String()) 198 } 199 } 200 201 // TestSayDirektesFallbeckenOhneEspeak: Linux ohne espeak (nur fake spd-say) 202 // und sink=null verwirft tonlos mit Exit 0 — das spd-say wird NICHT gerufen 203 // (null-Sink darf nie Ton erzeugen). 204 func TestSayDirektesFallbeckenOhneEspeak(t *testing.T) { 205 dir := t.TempDir() 206 seen := filepath.Join(dir, "seen.txt") 207 fakeTTSBin(t, dir, "spd-say", `printf '%s\n' "$*" > "`+seen+`"`) 208 // PATH NUR auf das fake-spd-say-Verzeichnis: kein echtes espeak, kein 209 // echtes spd-say dazwischen. 210 t.Setenv("PATH", dir) 211 setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"}) 212 213 var out, errBuf bytes.Buffer 214 code := runCLI([]string{"Hallo Welt"}, &out, &errBuf) 215 if code != exitOK { 216 t.Fatalf("talk (Linux ohne espeak, sink=null): Exit-Code = %d, want %d; stderr = %q", code, exitOK, errBuf.String()) 217 } 218 if _, err := os.Stat(seen); err == nil { 219 t.Error("spd-say wurde gerufen — null-Sink hätte die Ansage verworfen müssen") 220 } 221 if !strings.Contains(errBuf.String(), "verworfen") { 222 t.Errorf("stderr = %q, will Verwurf-Hinweis", errBuf.String()) 223 } 224 } 225 226 // TestSayDirektesFallbeckenMitOtoSink: ohne espeak, mit (fake) spd-say und 227 // dem Default-Sink oto muss die Direktansage über spd-say laufen. 228 func TestSayDirektesFallbeckenMitOtoSink(t *testing.T) { 229 dir := t.TempDir() 230 seen := filepath.Join(dir, "seen.txt") 231 fakeTTSBin(t, dir, "spd-say", `printf '%s\n' "$*" > "`+seen+`"`) 232 t.Setenv("PATH", dir) 233 setGetenv(t, nil) // Default-Sink oto, keine Env-Overrides 234 235 var out, errBuf bytes.Buffer 236 code := runCLI([]string{"Hallo Welt"}, &out, &errBuf) 237 if code != exitOK { 238 t.Fatalf("talk (Direktansage): Exit-Code = %d, want %d; stderr = %q", code, exitOK, errBuf.String()) 239 } 240 args, err := os.ReadFile(seen) 241 if err != nil { 242 t.Fatalf("fake spd-say nicht gerufen: %v", err) 243 } 244 if got := strings.TrimSpace(string(args)); got != "-w -l de Hallo Welt" { 245 t.Errorf("spd-say ARGS = %q, want %q", got, "-w -l de Hallo Welt") 246 } 247 } 248 go.mod --- Text 1 1 module code.m3ta.dev/m3tam3re/talk 2 2 3 3 go 1.26.8 4 5 require ( 6 github.com/BurntSushi/toml v1.6.0 7 github.com/ebitengine/oto/v3 v3.5.0 8 ) 9 10 require ( 11 github.com/ebitengine/purego v0.11.0 // indirect 12 github.com/jfreymuth/pulse v0.1.3 // indirect 13 golang.org/x/sys v0.47.0 // indirect 14 ) go.sum --- Text 1 github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= 2 github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 3 github.com/ebitengine/oto/v3 v3.5.0 h1:8ZDhKP3Hhslyb9NL3g4HWvhRQqwI+hKOHAXAqD6GPXs= 4 github.com/ebitengine/oto/v3 v3.5.0/go.mod h1:Elkm7yzTRns3w2efvibzVOoQ65YOwmec9a76dCiK10o= 5 github.com/ebitengine/purego v0.11.0 h1:jhp/D+Nyv7UUW8HAcmcjt2N2rYrYi9m3SL21k0Ua/NI= 6 github.com/ebitengine/purego v0.11.0/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ= 7 github.com/jfreymuth/pulse v0.1.3 h1:bc5TdxiB8E+2INnFjFWWgyfgXtz2IyNNNCX+Wt/ZD14= 8 github.com/jfreymuth/pulse v0.1.3/go.mod h1:cpYspI6YljhkUf1WLXLLDmeaaPFc3CnGLjDZf9dZ4no= 9 golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= 10 golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 11 internal/audio/audio.go --- Go 1 package audio 2 3 import ( 4 "context" 5 "fmt" 6 "io" 7 ) 8 9 // Sink-Namen (Teil des öffentlichen Vertrags; Spec: audio.sink = "oto"|"null"). 10 const ( 11 NameOto = "oto" 12 NameNull = "null" 13 ) 14 15 // Sink gibt Audio-Bytes wieder. Die Bytes stammen aus Provider.Synthesize; 16 // oto versteht RIFF/WAVE (PCM) — das Lieferformat von osbuiltin. 17 type Sink interface { 18 // Play spielt die Daten blockierend ab (bis zum Ende oder bis ctx 19 // abgebrochen wird). 20 Play(ctx context.Context, data []byte) error 21 22 // Name liefert den Sink-Namen (audio.sink-Konfiguration). 23 Name() string 24 } 25 26 // NewSink liefert den Sink zum konfigurierten Namen ("oto" oder "null"). 27 // Unbekannte Namen sind ein klarer Fehler. 28 func NewSink(name string) (Sink, error) { 29 switch name { 30 case NameOto: 31 return OtoSink{}, nil 32 case NameNull: 33 return NullSink{}, nil 34 default: 35 return nil, fmt.Errorf("unbekannter audio sink %q (erlaubt: %q, %q)", name, NameOto, NameNull) 36 } 37 } 38 39 // NullSink verwirft jede Ansage ohne Ton — für CI, Server und Tests. Wurde 40 // ein W gesetzt, loggt Play eine Zeile darüber (der CLI-Pfad hängt stderr 41 // an, damit „läuft ohne Ton durch" auch sichtbar bleibt). 42 type NullSink struct { 43 W io.Writer // optionaler Log-Output; nil = still verwerfen 44 } 45 46 // Name implementiert Sink. 47 func (NullSink) Name() string { return NameNull } 48 49 // Play implementiert Sink: verwirft die Daten. 50 func (n NullSink) Play(ctx context.Context, data []byte) error { 51 if n.W != nil { 52 fmt.Fprintf(n.W, "audio: %d Bytes verworfen (null sink)\n", len(data)) 53 } 54 return nil 55 } 56 internal/audio/audio_test.go --- Go 1 package audio 2 3 import ( 4 "bytes" 5 "context" 6 "encoding/binary" 7 "math" 8 "os" 9 "strings" 10 "testing" 11 ) 12 13 // buildWAV baut nach RIFF-Spezifikation eine WAV-Datei (unabhängig vom 14 // Parser implementiert, damit Parser und Test-Fixture nicht denselben 15 // Fehler teilen). 16 func buildWAV(audioFormat uint16, channels, rate, bits int, samples []byte) []byte { 17 var buf bytes.Buffer 18 buf.WriteString("RIFF") 19 payload := 28 + len(samples) // fmt (8+16) + data (8+n) + 4 bytes WAVE 20 binary.Write(&buf, binary.LittleEndian, uint32(payload)) 21 buf.WriteString("WAVE") 22 23 buf.WriteString("fmt ") 24 binary.Write(&buf, binary.LittleEndian, uint32(16)) 25 binary.Write(&buf, binary.LittleEndian, audioFormat) 26 binary.Write(&buf, binary.LittleEndian, uint16(channels)) 27 binary.Write(&buf, binary.LittleEndian, uint32(rate)) 28 blockAlign := channels * bits / 8 29 binary.Write(&buf, binary.LittleEndian, uint32(rate*blockAlign)) 30 binary.Write(&buf, binary.LittleEndian, uint16(blockAlign)) 31 binary.Write(&buf, binary.LittleEndian, uint16(bits)) 32 33 buf.WriteString("data") 34 binary.Write(&buf, binary.LittleEndian, uint32(len(samples))) 35 buf.Write(samples) 36 return buf.Bytes() 37 } 38 39 func TestDecodeWAV(t *testing.T) { 40 t.Run("16-bit mono", func(t *testing.T) { 41 wav := buildWAV(1, 1, 22050, 16, []byte{1, 2, 3, 4}) 42 f, pcm, err := decodeWAV(wav) 43 if err != nil { 44 t.Fatalf("decodeWAV: %v", err) 45 } 46 if f.SampleRate != 22050 || f.Channels != 1 || f.BitsPerCoded != 16 || f.Float { 47 t.Errorf("Format = %+v", f) 48 } 49 if string(pcm) != "\x01\x02\x03\x04" { 50 t.Errorf("PCM = %x", pcm) 51 } 52 }) 53 t.Run("8-bit stereo mit Extra-Chunk", func(t *testing.T) { 54 wav := buildWAV(1, 2, 8000, 8, []byte{7, 8, 9}) 55 // LIST-Chunk (word-aligned, ungerade Länge) zwischen fmt und data einfügen 56 withList := append(wav[:36], append([]byte("LIST\x03\x00\x00\x00abc\x00"), wav[36:]...)...) 57 f, pcm, err := decodeWAV(withList) 58 if err != nil { 59 t.Fatalf("decodeWAV: %v", err) 60 } 61 if f.BitsPerCoded != 8 || f.Channels != 2 { 62 t.Errorf("Format = %+v", f) 63 } 64 if string(pcm) != "\x07\x08\x09" { 65 t.Errorf("PCM = %x", pcm) 66 } 67 }) 68 t.Run("float32", func(t *testing.T) { 69 wav := buildWAV(3, 1, 44100, 32, []byte{0, 0, 0, 0}) 70 f, _, err := decodeWAV(wav) 71 if err != nil { 72 t.Fatalf("decodeWAV: %v", err) 73 } 74 if !f.Float || f.BitsPerCoded != 32 { 75 t.Errorf("Format = %+v, want Float32", f) 76 } 77 }) 78 t.Run("leerer data-Chunk ist erlaubt", func(t *testing.T) { 79 wav := buildWAV(1, 1, 22050, 16, nil) 80 _, pcm, err := decodeWAV(wav) 81 if err != nil { 82 t.Fatalf("decodeWAV: %v", err) 83 } 84 if len(pcm) != 0 { 85 t.Errorf("PCM = %x, want leer", pcm) 86 } 87 }) 88 } 89 90 func TestDecodeWAVFehler(t *testing.T) { 91 cases := []struct { 92 name string 93 data []byte 94 want string 95 }{ 96 {"zu kurz", []byte("RIFF"), "zu kurz"}, 97 {"kein riff", []byte("RIFX" + "\x00\x00\x00\x00" + "WAVE"), "RIFF"}, 98 {"24-bit pcm", buildWAV(1, 1, 44100, 24, []byte{1, 2, 3}), "nicht unterstütztes Format"}, 99 {"3 kanäle", buildWAV(1, 3, 44100, 16, []byte{1, 2, 3, 4, 5, 6}), "Kanäle"}, 100 {"ohne data", func() []byte { 101 wav := buildWAV(1, 1, 22050, 16, []byte{1, 2}) 102 return wav[:36] // nur fmt-Chunk behalten 103 }(), "data"}, 104 {"kaputter chunk", append(buildWAV(1, 1, 22050, 16, nil), "data\xff\xff\xff\xff"...), "überragt"}, 105 } 106 for _, tc := range cases { 107 t.Run(tc.name, func(t *testing.T) { 108 _, _, err := decodeWAV(tc.data) 109 if err == nil || !strings.Contains(err.Error(), tc.want) { 110 t.Errorf("decodeWAV-Fehler = %v, will Meldung mit %q", err, tc.want) 111 } 112 }) 113 } 114 } 115 116 func TestNewSink(t *testing.T) { 117 for _, name := range []string{NameOto, NameNull} { 118 s, err := NewSink(name) 119 if err != nil { 120 t.Fatalf("NewSink(%q): %v", name, err) 121 } 122 if s.Name() != name { 123 t.Errorf("NewSink(%q).Name() = %q", name, s.Name()) 124 } 125 } 126 if _, err := NewSink("mpv"); err == nil { 127 t.Error("NewSink(\"mpv\") soll fehlschlagen") 128 } 129 } 130 131 func TestNullSinkVerwirftUndLoggt(t *testing.T) { 132 var buf bytes.Buffer 133 sink := NullSink{W: &buf} 134 if err := sink.Play(context.Background(), []byte("audio")); err != nil { 135 t.Fatalf("NullSink.Play: %v", err) 136 } 137 if !strings.Contains(buf.String(), "verworfen") { 138 t.Errorf("NullSink-Log = %q, will Verwurf-Zeile", buf.String()) 139 } 140 if sink.Name() != NameNull { 141 t.Errorf("Name() = %q", sink.Name()) 142 } 143 } 144 145 // TestOtoSinkUngueltigeDaten: OtoSink.Play muss an der WAV-Dekodierung 146 // scheitern, bevor irgend ein Audio-Gerät angefasst wird — das ist der 147 // CI-/Server-sichere Fehlerpfad des oto-Sinks. 148 func TestOtoSinkUngueltigeDaten(t *testing.T) { 149 err := (OtoSink{}).Play(context.Background(), []byte("kein audio")) 150 if err == nil { 151 t.Fatal("OtoSink.Play mit Müll: kein Fehler") 152 } 153 if !strings.Contains(err.Error(), "wav:") { 154 t.Errorf("Fehler soll aus dem WAV-Parser kommen: %v", err) 155 } 156 } 157 158 // TestOtoSinkEchteWiedergabe läuft nur mit TALK_TEST_REAL_AUDIO=1 (erzeugt 159 // echten Ton); Standard-Testläufe bleiben stumm. 160 func TestOtoSinkEchteWiedergabe(t *testing.T) { 161 if testing.Short() { 162 t.Skip("Übersprungen (-short)") 163 } 164 if os.Getenv("TALK_TEST_REAL_AUDIO") != "1" { 165 t.Skip("Übersprungen (hörbar) — mit TALK_TEST_REAL_AUDIO=1 ausführen") 166 } 167 // 0,1 s 440-Hz-Sinus, 16 Bit mono 22050 Hz. 168 const rate = 22050 169 samples := make([]byte, rate/10*2) 170 for i := 0; i < rate/10; i++ { 171 v := int16(8000 * math.Sin(float64(i)/float64(rate)*2*math.Pi*440)) 172 binary.LittleEndian.PutUint16(samples[i*2:], uint16(v)) 173 } 174 if err := (OtoSink{}).Play(context.Background(), buildWAV(1, 1, rate, 16, samples)); err != nil { 175 t.Fatalf("OtoSink.Play: %v", err) 176 } 177 } 178 internal/audio/doc.go --- Go 1 // Package audio implementiert die Au 1 // Package audio implementiert die Au . dio-Ausgabe von talk über das . dio-Ausgabe von talk über das 2 // Sink-Interface: Playback embedded 2 // Sink-Interface: Playback embedded . via oto (PCM/WAV) und ein null-Sink, . via oto (RIFF/WAVE-PCM) und ein 3 // das verwirft (CI, Server, Tests). 3 // null-Sink, das verwirft (CI, Serve . Auswahl per Config audio.sink. . r, Tests). Auswahl per Config 4 // Implementierung folgt in Task 02. 4 // audio.sink = "oto" | "null" (Defau . . lt oto). 5 package audio 5 package audio internal/audio/oto.go --- Go 1 package audio 2 3 import ( 4 "bytes" 5 "context" 6 "fmt" 7 "sync" 8 "time" 9 10 "github.com/ebitengine/oto/v3" 11 ) 12 13 // OtoSink spielt Audio embedded über oto (github.com/ebitengine/oto/v3, 14 // gepinnt auf v3.5.0) ab — kein externer Player nötig. oto lädt die 15 // Audiotreiber zur Laufzeit ohne CGO (purego), dadurch bleibt talk 16 // cross-compilierbar. 17 type OtoSink struct{} 18 19 // Name implementiert Sink. 20 func (OtoSink) Name() string { return NameOto } 21 22 // contextCache hält je PCM-Format einen oto-Kontext vor: oto unterstützt 23 // nur EINEN Kontext pro Prozess ("Creating multiple contexts is NOT 24 // supported"), und ein Kontext ist an genau ein Format gebunden. Die CLI 25 // spielt pro Prozess einmal (Task 02); der Daemon (Task 04) profitiert vom 26 // Cache. 27 var contextCache = struct { 28 sync.Mutex 29 byFormat map[pcmFormat]*otoContext 30 }{byFormat: map[pcmFormat]*otoContext{}} 31 32 type otoContext struct { 33 ctx *oto.Context 34 ready chan struct{} 35 } 36 37 // otoFormat mappt ein PCM-Format auf das oto-Format. 38 func (f pcmFormat) otoFormat() oto.Format { 39 switch { 40 case f.Float: 41 return oto.FormatFloat32LE 42 case f.BitsPerCoded == 8: 43 return oto.FormatUnsignedInt8 44 default: 45 return oto.FormatSignedInt16LE 46 } 47 } 48 49 // contextFor liefert den (gecachten) oto-Kontext für ein Format. 50 func contextFor(f pcmFormat) (*otoContext, error) { 51 contextCache.Lock() 52 defer contextCache.Unlock() 53 if c, ok := contextCache.byFormat[f]; ok { 54 return c, nil 55 } 56 op := &oto.NewContextOptions{ 57 SampleRate: f.SampleRate, 58 ChannelCount: f.Channels, 59 Format: f.otoFormat(), 60 } 61 ctx, ready, err := oto.NewContext(op) 62 if err != nil { 63 return nil, fmt.Errorf("oto: Kontext (Audio-Gerät?) fehlgeschlagen: %w", err) 64 } 65 c := &otoContext{ctx: ctx, ready: ready} 66 contextCache.byFormat[f] = c 67 return c, nil 68 } 69 70 // Play implementiert Sink: dekodiert WAV (RIFF/PCM) und spielt die Samples 71 // blockierend ab. Fehler des Audio-Geräts oder des Formats werden klar 72 // gemeldet. 73 func (OtoSink) Play(ctx context.Context, data []byte) error { 74 f, pcm, err := decodeWAV(data) 75 if err != nil { 76 return err 77 } 78 c, err := contextFor(f) 79 if err != nil { 80 return err 81 } 82 select { 83 case <-c.ready: 84 if err := c.ctx.Err(); err != nil { 85 return fmt.Errorf("oto: Initialisierung fehlgeschlagen: %w", err) 86 } 87 case <-ctx.Done(): 88 return ctx.Err() 89 } 90 91 player := c.ctx.NewPlayer(bytes.NewReader(pcm)) 92 defer player.Close() 93 player.Play() 94 for player.IsPlaying() { 95 if err := ctx.Err(); err != nil { 96 player.Close() 97 return err 98 } 99 if err := player.Err(); err != nil { 100 return fmt.Errorf("oto: Wiedergabe fehlgeschlagen: %w", err) 101 } 102 time.Sleep(5 * time.Millisecond) 103 } 104 return player.Err() 105 } 106 internal/audio/wav.go --- Go 1 package audio 2 3 import ( 4 "encoding/binary" 5 "fmt" 6 ) 7 8 // pcmFormat beschreibt rohe PCM-Samples, wie sie oto zur Wiedergabe 9 // braucht. 10 type pcmFormat struct { 11 SampleRate int 12 Channels int 13 BitsPerCoded int // 8 (unsigned), 16 (signed LE), 32 (nur IEEE float) 14 Float bool 15 } 16 17 // decodeWAV zerlegt RIFF/WAVE-Bytes in Format und PCM-Daten. 18 // 19 // Unterstützt: PCM (AudioFormat 1) mit 8/16 Bit sowie IEEE-Float 20 // (AudioFormat 3) mit 32 Bit; Mono und Stereo. Alles andere — erst recht 21 // komprimierte Formate — ist ein klarer Fehler; die OS-TTS-Wege von 22 // osbuiltin liefern ausschließlich 16-Bit-PCM-WAV. 23 func decodeWAV(data []byte) (pcmFormat, []byte, error) { 24 var f pcmFormat 25 if len(data) < 12 { 26 return f, nil, fmt.Errorf("wav: zu kurz für RIFF-Header (%d Bytes)", len(data)) 27 } 28 if string(data[0:4]) != "RIFF" || string(data[8:12]) != "WAVE" { 29 return f, nil, fmt.Errorf("wav: kein RIFF/WAVE-Header") 30 } 31 32 var haveFmt bool 33 var payload []byte 34 pos := 12 35 for pos+8 <= len(data) { 36 id := string(data[pos : pos+4]) 37 size := int(binary.LittleEndian.Uint32(data[pos+4 : pos+8])) 38 body := pos + 8 39 if body+size > len(data) { 40 return f, nil, fmt.Errorf("wav: Chunk %q überragt die Datei (Size %d)", id, size) 41 } 42 switch id { 43 case "fmt ": 44 if size < 16 { 45 return f, nil, fmt.Errorf("wav: fmt-Chunk zu kurz (%d)", size) 46 } 47 audioFormat := binary.LittleEndian.Uint16(data[body : body+2]) 48 f.Channels = int(binary.LittleEndian.Uint16(data[body+2 : body+4])) 49 f.SampleRate = int(binary.LittleEndian.Uint32(data[body+4 : body+8])) 50 f.BitsPerCoded = int(binary.LittleEndian.Uint16(data[body+14 : body+16])) 51 switch { 52 case audioFormat == 1 && (f.BitsPerCoded == 8 || f.BitsPerCoded == 16): 53 case audioFormat == 3 && f.BitsPerCoded == 32: 54 f.Float = true 55 default: 56 return f, nil, fmt.Errorf("wav: nicht unterstütztes Format (AudioFormat %d, %d Bit) — erlaubt: PCM 8/16 Bit, Float 32 Bit", 57 audioFormat, f.BitsPerCoded) 58 } 59 if f.Channels < 1 || f.Channels > 2 { 60 return f, nil, fmt.Errorf("wav: %d Kanäle (erlaubt: 1, 2)", f.Channels) 61 } 62 if f.SampleRate <= 0 { 63 return f, nil, fmt.Errorf("wav: SampleRate %d", f.SampleRate) 64 } 65 haveFmt = true 66 case "data": 67 payload = data[body : body+size] 68 } 69 pos = body + size 70 if size%2 == 1 { // Chunks sind word-aligned 71 pos++ 72 } 73 } 74 if !haveFmt { 75 return f, nil, fmt.Errorf("wav: kein fmt-Chunk") 76 } 77 if payload == nil { 78 return f, nil, fmt.Errorf("wav: kein data-Chunk") 79 } 80 return f, payload, nil 81 } 82 internal/config/config.go --- Go 1 package config 2 3 import ( 4 "errors" 5 "fmt" 6 "os" 7 "path/filepath" 8 9 "github.com/BurntSushi/toml" 10 ) 11 12 // Erlaubte Werte für audio.sink (Teil des öffentlichen Vertrags; Spec: 13 // „Audio-Sink wählbar inkl. null"). 14 const ( 15 SinkOto = "oto" 16 SinkNull = "null" 17 ) 18 19 // Config ist der effektiv geladene Konfigurations-Stand von talk. Task 02 20 // führt nur audio.sink; spätere Tasks ergänzen Felder (Provider-Endpunkte, 21 // Voice-Mapping, server, queue …), ohne die Auflösungsreihenfolge zu ändern. 22 type Config struct { 23 Audio Audio `toml:"audio"` 24 } 25 26 // Audio steuert die Audio-Ausgabe. 27 type Audio struct { 28 // Sink wählt die Wiedergabe: "oto" (embedded Playback, Default) oder 29 // "null" (verwirft — CI, Server, Tests). 30 Sink string `toml:"sink"` 31 } 32 33 // Defaults liefert die eingebaute Default-Konfiguration (unterste Schicht 34 // der Auflösung). 35 func Defaults() Config { 36 return Config{Audio: Audio{Sink: SinkOto}} 37 } 38 39 // Options steuert Load. Alle Felder sind injizierbar, damit die Unit-Tests 40 // ohne echte HOME-/Env-Umgebung und ohne echte Dateien auskommen. 41 type Options struct { 42 // File überschreibt den platform-nativen Pfad der config.toml (Tests 43 // injizieren hier eine Temp-Datei). Leer: Dir() + "config.toml". 44 File string 45 46 // Getenv liefert Umgebungsvariablen (Default: os.Getenv). Über TALK_* 47 // laufen die Env-Overrides sowie die Pfadauflösung (HOME, 48 // XDG_CONFIG_HOME, APPDATA). 49 Getenv func(string) string 50 } 51 52 // Load löst die Konfiguration schichtweise auf: Defaults → config.toml 53 // (falls vorhanden) → Env-Overrides (TALK_AUDIO_SINK schlägt die Datei). 54 // Env- und Datei-Validierungsfehler werden als klarer Fehler gemeldet, nie 55 // still ignoriert. Fehlt das Config-Verzeichnis (z. B. keine HOME-Variable), 56 // läuft talk mit Defaults + Env weiter — die Datei ist optional, kaputte 57 // Umgebungen dürfen talk nicht unbrauchbar machen. 58 func Load(opts Options) (Config, error) { 59 if opts.Getenv == nil { 60 opts.Getenv = os.Getenv 61 } 62 cfg := Defaults() 63 64 path := opts.File 65 if path == "" { 66 dir, err := Dir(opts.Getenv) 67 if err != nil { 68 // Kein auffindbares Config-Verzeichnis: optionale Datei entfällt. 69 return applyEnv(cfg, opts.Getenv) 70 } 71 path = filepath.Join(dir, fileName) 72 } 73 74 if _, err := toml.DecodeFile(path, &cfg); err != nil { 75 if !errors.Is(err, os.ErrNotExist) { 76 return cfg, fmt.Errorf("config %s: %w", path, err) 77 } 78 } 79 return applyEnv(cfg, opts.Getenv) 80 } 81 82 // applyEnv wendet die Env-Overrides an und validiert das Ergebnis. 83 func applyEnv(cfg Config, getenv func(string) string) (Config, error) { 84 if v := getenv("TALK_AUDIO_SINK"); v != "" { 85 cfg.Audio.Sink = v 86 } 87 switch cfg.Audio.Sink { 88 case SinkOto, SinkNull: 89 return cfg, nil 90 default: 91 return cfg, fmt.Errorf("audio.sink: ungültiger Wert %q (erlaubt: %q, %q)", 92 cfg.Audio.Sink, SinkOto, SinkNull) 93 } 94 } 95 internal/config/config_test.go --- Go 1 package config 2 3 import ( 4 "os" 5 "path/filepath" 6 "strings" 7 "testing" 8 ) 9 10 func env(kv map[string]string) func(string) string { 11 return func(k string) string { return kv[k] } 12 } 13 14 func TestDirForPlattformpfade(t *testing.T) { 15 cases := []struct { 16 name string 17 goos string 18 env map[string]string 19 want string 20 errMsg string 21 }{ 22 { 23 name: "linux mit HOME", 24 goos: "linux", 25 env: map[string]string{"HOME": "/home/u"}, 26 want: "/home/u/.config/talk", 27 }, 28 { 29 name: "linux XDG hat Vorrang", 30 goos: "linux", 31 env: map[string]string{"HOME": "/home/u", "XDG_CONFIG_HOME": "/xdg"}, 32 want: "/xdg/talk", 33 }, 34 { 35 name: "linux relativer XDG wird ignoriert", 36 goos: "linux", 37 env: map[string]string{"HOME": "/home/u", "XDG_CONFIG_HOME": "relativ"}, 38 want: "/home/u/.config/talk", 39 }, 40 { 41 name: "linux ohne HOME ist Fehler", 42 goos: "linux", 43 env: map[string]string{}, 44 errMsg: ErrNoHome.Error(), 45 }, 46 { 47 name: "macos", 48 goos: "darwin", 49 env: map[string]string{"HOME": "/Users/u"}, 50 want: "/Users/u/Library/Application Support/talk", 51 }, 52 { 53 name: "macos ohne HOME ist Fehler", 54 goos: "darwin", 55 env: map[string]string{}, 56 errMsg: ErrNoHome.Error(), 57 }, 58 { 59 name: "windows", 60 goos: "windows", 61 env: map[string]string{"APPDATA": `C:\Users\u\AppData\Roaming`}, 62 want: filepath.Join(`C:\Users\u\AppData\Roaming`, "talk"), 63 }, 64 { 65 name: "windows ohne APPDATA ist Fehler", 66 goos: "windows", 67 env: map[string]string{}, 68 errMsg: ErrNoHome.Error(), 69 }, 70 } 71 for _, tc := range cases { 72 t.Run(tc.name, func(t *testing.T) { 73 got, err := dirFor(tc.goos, env(tc.env)) 74 if tc.errMsg != "" { 75 if err == nil || err.Error() != tc.errMsg { 76 t.Fatalf("dirFor(%q) Fehler = %v, want %q", tc.goos, err, tc.errMsg) 77 } 78 return 79 } 80 if err != nil { 81 t.Fatalf("dirFor(%q): unerwarteter Fehler %v", tc.goos, err) 82 } 83 if got != tc.want { 84 t.Errorf("dirFor(%q) = %q, want %q", tc.goos, got, tc.want) 85 } 86 }) 87 } 88 } 89 90 // TestLoadMerge ist tischgesteuert für die Schichtenfolge 91 // Defaults → TOML-Datei → Env-Override. 92 func TestLoadMerge(t *testing.T) { 93 writeTOML := func(t *testing.T, content string) string { 94 t.Helper() 95 path := filepath.Join(t.TempDir(), "config.toml") 96 if err := os.WriteFile(path, []byte(content), 0o644); err != nil { 97 t.Fatal(err) 98 } 99 return path 100 } 101 102 t.Run("nur Defaults", func(t *testing.T) { 103 cfg, err := Load(Options{ 104 File: filepath.Join(t.TempDir(), "gibt-es-nicht.toml"), 105 Getenv: env(nil), 106 }) 107 if err != nil { 108 t.Fatalf("Load: %v", err) 109 } 110 if cfg.Audio.Sink != SinkOto { 111 t.Errorf("Audio.Sink = %q, want Default %q", cfg.Audio.Sink, SinkOto) 112 } 113 }) 114 115 t.Run("TOML überschreibt Default", func(t *testing.T) { 116 cfg, err := Load(Options{ 117 File: writeTOML(t, "[audio]\nsink = \"null\"\n"), 118 Getenv: env(nil), 119 }) 120 if err != nil { 121 t.Fatalf("Load: %v", err) 122 } 123 if cfg.Audio.Sink != SinkNull { 124 t.Errorf("Audio.Sink = %q, want %q aus TOML", cfg.Audio.Sink, SinkNull) 125 } 126 }) 127 128 t.Run("leere TOML-Sektion lässt Default stehen", func(t *testing.T) { 129 cfg, err := Load(Options{ 130 File: writeTOML(t, "# nur ein Kommentar\n[audio]\n"), 131 Getenv: env(nil), 132 }) 133 if err != nil { 134 t.Fatalf("Load: %v", err) 135 } 136 if cfg.Audio.Sink != SinkOto { 137 t.Errorf("Audio.Sink = %q, want Default %q", cfg.Audio.Sink, SinkOto) 138 } 139 }) 140 141 t.Run("Env schlägt Datei", func(t *testing.T) { 142 cfg, err := Load(Options{ 143 File: writeTOML(t, "[audio]\nsink = \"oto\"\n"), 144 Getenv: env(map[string]string{"TALK_AUDIO_SINK": "null"}), 145 }) 146 if err != nil { 147 t.Fatalf("Load: %v", err) 148 } 149 if cfg.Audio.Sink != SinkNull { 150 t.Errorf("Audio.Sink = %q, want Env-Override %q", cfg.Audio.Sink, SinkNull) 151 } 152 }) 153 154 t.Run("Env allein ohne Datei", func(t *testing.T) { 155 cfg, err := Load(Options{ 156 File: filepath.Join(t.TempDir(), "gibt-es-nicht.toml"), 157 Getenv: env(map[string]string{"TALK_AUDIO_SINK": "null"}), 158 }) 159 if err != nil { 160 t.Fatalf("Load: %v", err) 161 } 162 if cfg.Audio.Sink != SinkNull { 163 t.Errorf("Audio.Sink = %q, want %q", cfg.Audio.Sink, SinkNull) 164 } 165 }) 166 167 t.Run("ungültiger Sink-Wert in Datei ist klarer Fehler", func(t *testing.T) { 168 _, err := Load(Options{ 169 File: writeTOML(t, "[audio]\nsink = \"mpv\"\n"), 170 Getenv: env(nil), 171 }) 172 if err == nil || !strings.Contains(err.Error(), "audio.sink") { 173 t.Fatalf("Load-Fehler = %v, will klaren audio.sink-Fehler", err) 174 } 175 }) 176 177 t.Run("ungültiger Env-Wert ist klarer Fehler", func(t *testing.T) { 178 _, err := Load(Options{ 179 File: filepath.Join(t.TempDir(), "gibt-es-nicht.toml"), 180 Getenv: env(map[string]string{"TALK_AUDIO_SINK": "bogus"}), 181 }) 182 if err == nil || !strings.Contains(err.Error(), "TALK") && !strings.Contains(err.Error(), "audio.sink") { 183 t.Fatalf("Load-Fehler = %v, will klaren audio.sink-Fehler", err) 184 } 185 }) 186 187 t.Run("kaputte TOML-Syntax ist klarer Fehler", func(t *testing.T) { 188 _, err := Load(Options{ 189 File: writeTOML(t, "[audio\nsink = \n"), 190 Getenv: env(nil), 191 }) 192 if err == nil { 193 t.Fatal("Load bei kaputter TOML: kein Fehler") 194 } 195 }) 196 197 t.Run("ohne Home läuft Load mit Defaults weiter", func(t *testing.T) { 198 cfg, err := Load(Options{Getenv: env(nil)}) 199 if err != nil { 200 t.Fatalf("Load ohne HOME: %v", err) 201 } 202 if cfg.Audio.Sink != SinkOto { 203 t.Errorf("Audio.Sink = %q, want Default %q", cfg.Audio.Sink, SinkOto) 204 } 205 }) 206 } 207 internal/config/doc.go --- Go 1 // Package config implementiert die C 1 // Package config implementiert die C . onfig-Auflösung von talk: Defaults + . onfig-Auflösung von talk: Defaults + 2 // optionale config.toml im platform- 2 // optionale config.toml im platform- . nativen Verzeichnis (~/.config/talk, . nativen Verzeichnis (~/.config/talk, 3 // ~/Library/Application Support/talk 3 // ~/Library/Application Support/talk . , %APPDATA%\talk) + Env-Overrides . , %APPDATA%\talk) + Env-Overrides 4 // (TALK_*); Env schlägt Datei. Enthä 4 // (TALK_*); Env schlägt Datei. Pfade . lt Voice-Mapping voice.. . 5 // und Provider-Endpunkte (Fake-bar f 5 // Voice-Mapping voice.. und Provider-Endpunkte folgen mit 6 // Task 02. 6 // Task 03. 7 package config 7 package config internal/config/path.go --- Go 1 package config 2 3 import ( 4 "errors" 5 "path/filepath" 6 "runtime" 7 ) 8 9 // fileName ist der Name der Config-Datei im Config-Verzeichnis. 10 const fileName = "config.toml" 11 12 // ErrNoHome meldet, dass das Heimatverzeichnis nicht bestimmt werden kann 13 // (keine HOME- bzw. APPDATA-Umgebungsvariable). 14 var ErrNoHome = errors.New("Config-Verzeichnis nicht bestimmbar (HOME bzw. APPDATA fehlt)") 15 16 // Dir liefert das platform-native Config-Verzeichnis von talk auf dem 17 // aktuellen System: Linux ~/.config/talk (respektive $XDG_CONFIG_HOME/talk), 18 // macOS ~/Library/Application Support/talk, Windows %APPDATA%\talk. 19 func Dir(getenv func(string) string) (string, error) { 20 return dirFor(runtime.GOOS, getenv) 21 } 22 23 // dirFor ist die testbare Plattformlogik hinter Dir: goos wird als 24 // Parameter übergeben, damit die Pfade für alle Betriebssysteme vom 25 // Dev-Host aus tischgesteuert geprüft werden können. 26 // 27 // XDG_CONFIG_HOME wird nur berücksichtigt, wenn es einen absoluten Pfad 28 // enthält (XDG-Spezifikation); relative Werte werden ignoriert. 29 func dirFor(goos string, getenv func(string) string) (string, error) { 30 if getenv == nil { 31 return "", ErrNoHome 32 } 33 switch goos { 34 case "windows": 35 appdata := getenv("APPDATA") 36 if appdata == "" { 37 return "", ErrNoHome 38 } 39 return filepath.Join(appdata, "talk"), nil 40 case "darwin": 41 home := getenv("HOME") 42 if home == "" { 43 return "", ErrNoHome 44 } 45 return filepath.Join(home, "Library", "Application Support", "talk"), nil 46 default: // linux und sonstige unixartige Systeme 47 if xdg := getenv("XDG_CONFIG_HOME"); xdg != "" && filepath.IsAbs(xdg) { 48 return filepath.Join(xdg, "talk"), nil 49 } 50 home := getenv("HOME") 51 if home == "" { 52 return "", ErrNoHome 53 } 54 return filepath.Join(home, ".config", "talk"), nil 55 } 56 } 57 internal/langdetect/detect.go --- Go 1 package langdetect 2 3 import ( 4 "strings" 5 "unicode" 6 ) 7 8 // Detect erkennt die Sprache eines Ansage-Texts und liefert "de" oder "en". 9 // Default (auch bei leerem oder unentscheidbarem Text) ist "de". 10 // 11 // Heuristik: Common-Word-Listen deutscher und englischer Wörter plus 12 // Umlaute/ß als starke Deutsch-Signale. Englisch gewinnt nur bei strikt 13 // mehr englischen Signalen als deutschen; bei Gleichstand entscheidet der 14 // Default "de". Die Funktion ist rein (kein globaler Zustand, keine IO). 15 func Detect(text string) string { 16 var de, en int 17 for _, r := range text { 18 switch r { 19 case 'ä', 'ö', 'ü', 'ß', 'Ä', 'Ö', 'Ü': 20 de++ 21 } 22 } 23 24 for _, word := range strings.FieldsFunc(text, func(r rune) bool { 25 return !unicode.IsLetter(r) && r != '\'' && r != '’' 26 }) { 27 word = strings.ToLower(word) 28 if _, ok := germanWords[word]; ok { 29 de++ 30 } 31 if _, ok := englishWords[word]; ok { 32 en++ 33 } 34 } 35 36 if en > de { 37 return "en" 38 } 39 return "de" 40 } 41 42 // germanWords sind häufige deutsche Wörter, die im Englischen nicht 43 // vorkommen (Mehrdeutigkeiten wie „was" und „in" wurden weggelassen). 44 var germanWords = map[string]struct{}{ 45 "der": {}, "die": {}, "das": {}, "und": {}, "nicht": {}, "ist": {}, 46 "ein": {}, "eine": {}, "einen": {}, "einem": {}, "einer": {}, 47 "mit": {}, "für": {}, "auf": {}, "von": {}, "vom": {}, "zum": {}, 48 "zur": {}, "den": {}, "dem": {}, "des": {}, "wir": {}, "ihr": {}, 49 "sie": {}, "auch": {}, "als": {}, "wie": {}, "bei": {}, 50 "aus": {}, "nach": {}, "noch": {}, "nur": {}, "schon": {}, 51 "wenn": {}, "dann": {}, "aber": {}, "oder": {}, "doch": {}, 52 "mehr": {}, "sehr": {}, "über": {}, "unter": {}, "jetzt": {}, 53 "hier": {}, "heute": {}, "morgen": {}, "danke": {}, "bitte": {}, 54 "erfolgreich": {}, "fehlgeschlagen": {}, "fertig": {}, 55 "gestartet": {}, "beendet": {}, "abgeschlossen": {}, "warten": {}, 56 "läuft": {}, "kannst": {}, "muss": {}, "soll": {}, 57 "kein": {}, "keine": {}, "wieder": {}, "werden": {}, "wurde": {}, 58 "wurden": {}, "haben": {}, "geht": {}, "machen": {}, 59 } 60 61 // englishWords sind häufige englische Wörter, die im Deutschen nicht 62 // vorkommen. 63 var englishWords = map[string]struct{}{ 64 "the": {}, "is": {}, "not": {}, "and": {}, "with": {}, "for": {}, 65 "on": {}, "of": {}, "to": {}, "from": {}, "by": {}, "at": {}, 66 "or": {}, "but": {}, "if": {}, "then": {}, "when": {}, "how": {}, 67 "what": {}, "why": {}, "who": {}, "done": {}, "failed": {}, 68 "finished": {}, "started": {}, "completed": {}, "error": {}, 69 "warning": {}, "thanks": {}, "you": {}, "your": {}, 70 "we": {}, "it": {}, "this": {}, "that": {}, "these": {}, 71 "those": {}, "was": {}, "were": {}, "are": {}, "be": {}, 72 "been": {}, "has": {}, "have": {}, "had": {}, "can": {}, 73 "will": {}, "would": {}, "should": {}, "could": {}, "just": {}, 74 "only": {}, "still": {}, "already": {}, "now": {}, "today": {}, 75 "tomorrow": {}, "waiting": {}, "running": {}, "please": {}, 76 "needs": {}, "need": {}, "succeeded": {}, 77 } 78 internal/langdetect/detect_test.go --- Go 1 package langdetect 2 3 import "testing" 4 5 // TestDetect ist tischgesteuert: Eingabetext → erwartete Sprache. 6 // Der Default "de" gilt für leeren, unentscheidbaren und gemischten 7 // Text ohne englische Mehrheit. 8 func TestDetect(t *testing.T) { 9 cases := []struct { 10 name string 11 text string 12 want string 13 }{ 14 {"leer", "", "de"}, 15 {"nur whitespace", " \n\t ", "de"}, 16 {"nur interpunktion", "!!! ??? ...", "de"}, 17 {"deutsch satz", "Der Build wurde erfolgreich abgeschlossen und das Deployment ist fertig.", "de"}, 18 {"deutsch umlaute", "Größe früh Ärzteübung — schön daß es klappt", "de"}, 19 {"ß alleine", "weiß", "de"}, 20 {"englisch satz", "The build has finished and the deployment is done.", "en"}, 21 {"englisch imperativ", "Please wait until the tests are green.", "en"}, 22 {"englisch kurz", "done", "en"}, 23 {"gemischt englisch dominiert", "the build is done and ready for you", "en"}, 24 {"gemischt deutsch dominiert", "Der build ist done und fertig für dich", "de"}, 25 {"gemischt gleichstand", "der the", "de"}, 26 {"unklare sprache", "Build 2026-10-18 14:22 UTC", "de"}, 27 {"agenten-typisch en", "Task completed. Please review the changes and approve.", "en"}, 28 {"agenten-typisch de", "Aufgabe abgeschlossen. Bitte prüfen und freigeben.", "de"}, 29 } 30 for _, tc := range cases { 31 t.Run(tc.name, func(t *testing.T) { 32 if got := Detect(tc.text); got != tc.want { 33 t.Errorf("Detect(%q) = %q, want %q", tc.text, got, tc.want) 34 } 35 }) 36 } 37 } 38 internal/langdetect/doc.go --- Go 1 // Package langdetect erkennt die Spr 1 // Package langdetect erkennt die Spr . ache eines Ansage-Texts als reine . ache eines Ansage-Texts als reine 2 // Funktion Detect(text) → "de" | "en 2 // Funktion Detect(text) → "de" | "en . " (Default "de"; Heuristik über . " (Default "de"; Heuristik über 3 // Common-Word-Listen und Umlaute/ß). 3 // Common-Word-Listen und Umlaute/ß). . Implementierung folgt in Task 02. . 4 package langdetect 4 package langdetect internal/provider/doc.go --- Go 1 // Package provider implementiert die 1 // Package provider implementiert die . TTS-Provider-Kette von talk: . TTS-Provider von talk. Der Vertrag . 2 // (Name/Available/Synthesize) trägt . . die Fallback-Kette aus Task 03 2 // ElevenLabs → OpenAI TTS → Edge-TTS 3 // (ElevenLabs → OpenAI TTS → Edge-TT . → OS built-in (macOS say, Windows SA . S → OS built-in); die dokumentierte . PI, . 3 // Linux espeak/speech-dispatcher), ü 4 // optionale Erweiterung DirectSpeake . berschreibbar per Config/Flag, mit . r deckt Plattformen ab, deren OS-TTS 4 // Fallback bei Fehlern. Implementier 5 // keine Audio-Bytes liefern kann (Li . ung folgt in Task 02 (osbuiltin) und . nux speech-dispatcher). 5 // Task 03 (Cloud-Provider, Kette). . 6 package provider 6 package provider internal/provider/osbuiltin.go --- Go 1 package provider 2 3 import ( 4 "bytes" 5 "context" 6 "encoding/base64" 7 "encoding/binary" 8 "errors" 9 "fmt" 10 "os" 11 "os/exec" 12 "runtime" 13 "unicode/utf16" 14 ) 15 16 // OSBuiltin spricht über die im Betriebssystem eingebaute TTS-Runtime. 17 // Es ist der Notnagel-Provider der Kette: überall vorhanden, ohne Key und 18 // ohne Netz — aber die einfachste Stimme. 19 // 20 // Plattform-Mechanismen (Auswahl zur Laufzeit über runtime.GOOS, damit eine 21 // Binärversion alle Ziele kompiliert; jede Route ist reines exec ohne Shell): 22 // 23 // Linux: Synthesize über espeak/espeak-ng (--stdout → WAV-Bytes). 24 // Fehlt beides, liefert Synthesize ErrNoByteOutput und 25 // SpeakDirect spielt über spd-say (speech-dispatcher) ab — 26 // spd-say kann Audio nach eigener --help-Ausgabe nur selbst über 27 // den Daemon ausgeben, nicht in eine Datei/in einen Prozess 28 // schreiben (verifiziert am Dev-Host 2026-10-18). 29 // macOS: Synthesize über say (-o temporäre WAV-Datei, 30 // --data-format=LEI16@22050); Stimmen de→Anna, en→Samantha 31 // (vorinstalliert; ersetzt durch das Voice-Mapping in Task 03). 32 // Windows: Synthesize über PowerShell System.Speech in temporäre WAV- 33 // Datei (SetOutputToWaveFile); Sprache via SelectVoiceByHints 34 // mit Fallback auf die Systemstimme. 35 // 36 // Fehlende Binaries/Runtimes führen zu Available()==false bzw. klaren 37 // Fehlern — nie zu Panics. 38 type OSBuiltin struct{} 39 40 // Name implementiert Provider. 41 func (OSBuiltin) Name() string { return "osbuiltin" } 42 43 // Available implementiert Provider: true, wenn mindestens ein 44 // Plattform-Mechanismus vorhanden ist. 45 func (OSBuiltin) Available(ctx context.Context) bool { 46 switch runtime.GOOS { 47 case "darwin": 48 _, err := exec.LookPath("say") 49 return err == nil 50 case "windows": 51 _, err := exec.LookPath("powershell") 52 return err == nil 53 default: // linux und sonstige unixartige Systeme 54 for _, bin := range []string{"espeak", "espeak-ng", "spd-say"} { 55 if _, err := exec.LookPath(bin); err == nil { 56 return true 57 } 58 } 59 return false 60 } 61 } 62 63 // Synthesize implementiert Provider. Rückgabeformat: RIFF/WAVE (PCM) — 64 // abspielbar über jeden Sink des audio-Pakets. 65 func (p OSBuiltin) Synthesize(ctx context.Context, text, lang string) ([]byte, error) { 66 if text == "" { 67 return nil, fmt.Errorf("osbuiltin: leerer Text") 68 } 69 switch runtime.GOOS { 70 case "darwin": 71 return p.synthSay(ctx, text, lang) 72 case "windows": 73 return p.synthSAPI(ctx, text, lang) 74 default: 75 return p.synthEspeak(ctx, text, lang) 76 } 77 } 78 79 // SpeakDirect implementiert DirectSpeaker: direkte, blockierende Ausgabe 80 // über das OS-TTS mit eigener Wiedergabe. Notwendig auf Linux ohne espeak; 81 // auf macOS/Windows nicht erforderlich (Synthesize liefert WAV) und ist 82 // dort ein klarer Fehler. 83 func (p OSBuiltin) SpeakDirect(ctx context.Context, text, lang string) error { 84 if text == "" { 85 return fmt.Errorf("osbuiltin: leerer Text") 86 } 87 switch runtime.GOOS { 88 case "darwin", "windows": 89 return fmt.Errorf("osbuiltin: SpeakDirect auf %s nicht nötig — Synthesize liefert WAV; über einen Sink abspielen", runtime.GOOS) 90 default: 91 if _, err := exec.LookPath("spd-say"); err != nil { 92 return fmt.Errorf("osbuiltin: spd-say nicht gefunden (speech-dispatcher installieren oder espeak für Audio-Bytes): %w", err) 93 } 94 cmd := exec.CommandContext(ctx, "spd-say", "-w", "-l", lang, text) 95 if out, err := cmd.CombinedOutput(); err != nil { 96 return fmt.Errorf("osbuiltin: spd-say fehlgeschlagen: %w: %s", err, bytes.TrimSpace(out)) 97 } 98 return nil 99 } 100 } 101 102 // synthEspeak versucht espeak, dann espeak-ng. Beide schreiben WAV 103 // (RIFF/PCM) auf stdout. 104 func (OSBuiltin) synthEspeak(ctx context.Context, text, lang string) ([]byte, error) { 105 for _, bin := range []string{"espeak", "espeak-ng"} { 106 if _, err := exec.LookPath(bin); err != nil { 107 continue 108 } 109 cmd := exec.CommandContext(ctx, bin, "-v", lang, "--stdout", text) 110 out, err := cmd.Output() 111 if err != nil { 112 return nil, fmt.Errorf("osbuiltin: %s fehlgeschlagen: %w", bin, withStderr(err)) 113 } 114 if len(out) == 0 { 115 return nil, fmt.Errorf("osbuiltin: %s lieferte keine Audiodaten", bin) 116 } 117 return out, nil 118 } 119 return nil, fmt.Errorf("osbuiltin: unter %s kein espeak/espeak-ng für Audio-Bytes gefunden: %w", runtime.GOOS, ErrNoByteOutput) 120 } 121 122 // synthSay erzeugt WAV über das macOS-say-Kommando in einer temporären 123 // Datei. --data-format=LEI16@22050 erzwingt 16-Bit-PCM; die Endung .wav 124 // den WAVE-Container. 125 func (OSBuiltin) synthSay(ctx context.Context, text, lang string) ([]byte, error) { 126 if _, err := exec.LookPath("say"); err != nil { 127 return nil, fmt.Errorf("osbuiltin: say nicht gefunden: %w", err) 128 } 129 tmp, err := os.CreateTemp("", "talk-say-*.wav") 130 if err != nil { 131 return nil, fmt.Errorf("osbuiltin: temporäre Datei: %w", err) 132 } 133 name := tmp.Name() 134 tmp.Close() 135 defer os.Remove(name) 136 137 cmd := exec.CommandContext(ctx, "say", "-v", sayVoice(lang), 138 "--data-format=LEI16@22050", "-o", name, text) 139 if out, err := cmd.CombinedOutput(); err != nil { 140 return nil, fmt.Errorf("osbuiltin: say fehlgeschlagen: %w: %s", err, bytes.TrimSpace(out)) 141 } 142 data, err := os.ReadFile(name) 143 if err != nil { 144 return nil, fmt.Errorf("osbuiltin: say-Ausgabe lesen: %w", err) 145 } 146 return data, nil 147 } 148 149 // sayVoice mappt Sprachen auf vorinstallierte macOS-Stimmen. 150 func sayVoice(lang string) string { 151 if lang == "en" { 152 return "Samantha" // en-US 153 } 154 return "Anna" // de-DE 155 } 156 157 // synthSAPI erzeugt WAV über die Windows-SAPI (System.Speech) per 158 // PowerShell. Das Skript wird als -EncodedCommand (Base64/UTF-16LE) 159 // übergeben und der Text darin base64-dekodiert übergeben — dadurch gibt 160 // es kein Quoting-Problem mit Sonderzeichen. Ist die Wunschsprache nicht 161 // installiert, fällt SAPI auf die Systemstimme zurück (try/catch). 162 func (OSBuiltin) synthSAPI(ctx context.Context, text, lang string) ([]byte, error) { 163 if _, err := exec.LookPath("powershell"); err != nil { 164 return nil, fmt.Errorf("osbuiltin: powershell nicht gefunden: %w", err) 165 } 166 tmp, err := os.CreateTemp("", "talk-sapi-*.wav") 167 if err != nil { 168 return nil, fmt.Errorf("osbuiltin: temporäre Datei: %w", err) 169 } 170 name := tmp.Name() 171 tmp.Close() 172 defer os.Remove(name) 173 174 script := fmt.Sprintf(`$ErrorActionPreference = 'Stop' 175 Add-Type -AssemblyName System.Speech 176 $s = New-Object System.Speech.Synthesis.SpeechSynthesizer 177 try { 178 $culture = New-Object System.Globalization.CultureInfo('%s') 179 $s.SelectVoiceByHints([System.Speech.Synthesis.VoiceGender]::Female, [System.Speech.Synthesis.VoiceAge]::Adult, 0, $culture) 180 } catch { } 181 $s.SetOutputToWaveFile('%s') 182 $s.Speak([System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('%s'))) 183 $s.Dispose() 184 `, sapiCulture(lang), name, base64.StdEncoding.EncodeToString([]byte(text))) 185 186 cmd := exec.CommandContext(ctx, "powershell", "-NoProfile", "-STA", "-EncodedCommand", psEncode(script)) 187 if out, err := cmd.CombinedOutput(); err != nil { 188 return nil, fmt.Errorf("osbuiltin: SAPI fehlgeschlagen: %w: %s", err, bytes.TrimSpace(out)) 189 } 190 data, err := os.ReadFile(name) 191 if err != nil { 192 return nil, fmt.Errorf("osbuiltin: SAPI-Ausgabe lesen: %w", err) 193 } 194 return data, nil 195 } 196 197 // sapiCulture mappt Sprachen auf SAPI-Kulturbezeichner. 198 func sapiCulture(lang string) string { 199 if lang == "en" { 200 return "en-US" 201 } 202 return "de-DE" 203 } 204 205 // psEncode kodiert ein PowerShell-Skript für -EncodedCommand 206 // (UTF-16LE + Base64). 207 func psEncode(script string) string { 208 runes := utf16.Encode([]rune(script)) 209 buf := make([]byte, len(runes)*2) 210 for i, r := range runes { 211 binary.LittleEndian.PutUint16(buf[i*2:], r) 212 } 213 return base64.StdEncoding.EncodeToString(buf) 214 } 215 216 // withStderr reichert einen exec-Fehler um die gesammelte stderr-Ausgabe 217 // des Prozesses an, wenn vorhanden. 218 func withStderr(err error) error { 219 var exitErr *exec.ExitError 220 if errors.As(err, &exitErr) && len(exitErr.Stderr) > 0 { 221 return fmt.Errorf("%w: %s", err, bytes.TrimSpace(exitErr.Stderr)) 222 } 223 return err 224 } 225 internal/provider/osbuiltin_test.go --- Go 1 package provider 2 3 import ( 4 "context" 5 "errors" 6 "os" 7 "os/exec" 8 "path/filepath" 9 "strings" 10 "testing" 11 ) 12 13 // fakeBin schreibt ein ausführbares Shell-Skript namens name in dir, das 14 // body ausführt. Damit werden die externen Prozesse des Providers an der 15 // echten Prozessgrenze gefaked (PATH-Manipulation, kein Mock-Framework). 16 func fakeBin(t *testing.T, dir, name, body string) { 17 t.Helper() 18 script := "#!/bin/sh\n" + body + "\n" 19 path := filepath.Join(dir, name) 20 if err := os.WriteFile(path, []byte(script), 0o755); err != nil { 21 t.Fatal(err) 22 } 23 } 24 25 // emptyPATH setzt PATH auf ein Verzeichnis ohne einzige Binärdatei — 26 // kein espeak, kein spd-say. 27 func emptyPATH(t *testing.T) string { 28 t.Helper() 29 dir := t.TempDir() 30 t.Setenv("PATH", dir) 31 return dir 32 } 33 34 // prependPATH stellt dir vor den bestehenden PATH: Binaries darin 35 // überschatten System-Binaries gleichen Namens (fake espeak vor einem 36 // echten), während Hilfsprogramme wie cat weiterhin erreichbar bleiben. 37 func prependPATH(t *testing.T, dir string) { 38 t.Helper() 39 t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) 40 } 41 42 // TestOSBuiltinName und TestNewRegistry pinnen die Registry-Sicht. 43 func TestOSBuiltinName(t *testing.T) { 44 if got := (OSBuiltin{}).Name(); got != "osbuiltin" { 45 t.Errorf("Name() = %q, want %q", got, "osbuiltin") 46 } 47 } 48 49 func TestNewRegistry(t *testing.T) { 50 for _, name := range []string{"", "osbuiltin"} { 51 p, err := New(name) 52 if err != nil { 53 t.Fatalf("New(%q): %v", name, err) 54 } 55 if p.Name() != "osbuiltin" { 56 t.Errorf("New(%q).Name() = %q", name, p.Name()) 57 } 58 } 59 if _, err := New("elevenlabs"); err == nil { 60 t.Error("New(\"elevenlabs\") soll in diesem Stand fehlschlagen (Task 03 ergänzt)") 61 } else if !strings.Contains(err.Error(), "osbuiltin") { 62 t.Errorf("New-Fehler soll verfügbare Provider nennen: %v", err) 63 } 64 } 65 66 // TestAvailableOhneTTS: leere PATH-Umgebung → Available false. 67 func TestAvailableOhneTTS(t *testing.T) { 68 emptyPATH(t) 69 if (OSBuiltin{}).Available(context.Background()) { 70 t.Error("Available() = true ohne jedes TTS-Binary, want false") 71 } 72 } 73 74 // TestSynthesizeOhneTTS: fehlt byte-fähiges TTS, muss der Fehler 75 // ErrNoByteOutput sein (kein Panik, kein anderer Fehlertyp). 76 func TestSynthesizeOhneTTS(t *testing.T) { 77 emptyPATH(t) 78 _, err := (OSBuiltin{}).Synthesize(context.Background(), "Hallo", "de") 79 if err == nil { 80 t.Fatal("Synthesize ohne TTS: kein Fehler") 81 } 82 if !errors.Is(err, ErrNoByteOutput) { 83 t.Fatalf("Synthesize-Fehler = %v, want ErrNoByteOutput", err) 84 } 85 } 86 87 // TestSynthesizeMitFakeEspeak: ein gefaktes espeak liefert WAV-Bytes auf 88 // stdout; Synthesize muss genau diese Bytes durchreichen und die Stimme 89 // (-v ) sowie den Text übergeben bekommen haben. 90 func TestSynthesizeMitFakeEspeak(t *testing.T) { 91 dir := t.TempDir() 92 wav := filepath.Join(dir, "probe.wav") 93 if err := os.WriteFile(wav, minimalWAV, 0o644); err != nil { 94 t.Fatal(err) 95 } 96 seen := filepath.Join(dir, "seen.txt") 97 fakeBin(t, dir, "espeak", `printf '%s\n' "$*" > "`+seen+`" 98 cat "`+wav+`"`) 99 prependPATH(t, dir) 100 101 data, err := (OSBuiltin{}).Synthesize(context.Background(), "Hallo Welt", "de") 102 if err != nil { 103 t.Fatalf("Synthesize: %v", err) 104 } 105 if string(data) != string(minimalWAV) { 106 t.Errorf("Synthesize lieferte %d Bytes, want die WAV-Bytes des Fake-espeak (%d)", len(data), len(minimalWAV)) 107 } 108 args, err := os.ReadFile(seen) 109 if err != nil { 110 t.Fatalf("Fake-espeak wurde nicht aufgerufen: %v", err) 111 } 112 if got := string(args); got != "-v de --stdout Hallo Welt\n" { 113 t.Errorf("Fake-espeak ARGS = %q, want %q", got, "-v de --stdout Hallo Welt\n") 114 } 115 if !(OSBuiltin{}).Available(context.Background()) { 116 t.Error("Available() = false trotz espeak im PATH") 117 } 118 } 119 120 // TestSynthesizeLeererText ist ein klarer Fehler. 121 func TestSynthesizeLeererText(t *testing.T) { 122 if _, err := (OSBuiltin{}).Synthesize(context.Background(), "", "de"); err == nil { 123 t.Error("Synthesize(\"\") soll fehlschlagen") 124 } 125 } 126 127 // TestSynthesizeFehlgeschlagenesEspeak: espeak crasht (exit 1) → klarer 128 // Fehler, kein ErrNoByteOutput. 129 func TestSynthesizeFehlgeschlagenesEspeak(t *testing.T) { 130 dir := emptyPATH(t) 131 fakeBin(t, dir, "espeak", `echo "boom" >&2; exit 1`) 132 _, err := (OSBuiltin{}).Synthesize(context.Background(), "Hallo", "de") 133 if err == nil { 134 t.Fatal("Synthesize mit crashendem espeak: kein Fehler") 135 } 136 if errors.Is(err, ErrNoByteOutput) { 137 t.Errorf("Crashender espeak soll kein ErrNoByteOutput sein: %v", err) 138 } 139 if !strings.Contains(err.Error(), "boom") { 140 t.Errorf("Fehler soll stderr des Prozesses enthalten: %v", err) 141 } 142 } 143 144 // TestSpeakDirectMitFakeSpdSay: SpeakDirect ruft spd-say blockierend 145 // (-w) mit Sprache (-l ) und Text auf. 146 func TestSpeakDirectMitFakeSpdSay(t *testing.T) { 147 dir := emptyPATH(t) 148 seen := filepath.Join(dir, "seen.txt") 149 fakeBin(t, dir, "spd-say", `printf '%s\n' "$*" > "`+seen+`"`) 150 prependPATH(t, dir) 151 152 if err := (OSBuiltin{}).SpeakDirect(context.Background(), "Hallo Welt", "de"); err != nil { 153 t.Fatalf("SpeakDirect: %v", err) 154 } 155 args, err := os.ReadFile(seen) 156 if err != nil { 157 t.Fatalf("Fake-spd-say wurde nicht aufgerufen: %v", err) 158 } 159 if got := string(args); got != "-w -l de Hallo Welt\n" { 160 t.Errorf("Fake-spd-say ARGS = %q, want %q", got, "-w -l de Hallo Welt\n") 161 } 162 } 163 164 // TestSpeakDirectOhneSpdSay ist ein klarer Fehler. 165 func TestSpeakDirectOhneSpdSay(t *testing.T) { 166 emptyPATH(t) 167 if err := (OSBuiltin{}).SpeakDirect(context.Background(), "Hallo", "de"); err == nil { 168 t.Error("SpeakDirect ohne spd-say soll fehlschlagen") 169 } 170 } 171 172 // TestSpeakDirectEchtesSpdSay läuft nur, wenn TALK_TEST_REAL_SPD=1 gesetzt 173 // ist (erzeugt echte, hörbare Ausgabe über speech-dispatcher — einmalige 174 // manuelle Abnahme; Standard-Testläufe bleiben stumm). 175 func TestSpeakDirectEchtesSpdSay(t *testing.T) { 176 if os.Getenv("TALK_TEST_REAL_SPD") != "1" { 177 t.Skip("Übersprungen (hörbar) — mit TALK_TEST_REAL_SPD=1 ausführen") 178 } 179 if _, err := exec.LookPath("spd-say"); err != nil { 180 t.Skip("spd-say nicht installiert") 181 } 182 if err := (OSBuiltin{}).SpeakDirect(context.Background(), "Sprachtest über speech dispatcher", "de"); err != nil { 183 t.Fatalf("SpeakDirect gegen echtes spd-say: %v", err) 184 } 185 } 186 187 // minimalWAV ist eine minimal gültige RIFF/WAVE-Datei (PCM 16 Bit, mono, 188 // 8000 Hz) mit vier Samples — handgebaut nach der RIFF-Spezifikation, 189 // unabhängig vom Parser im audio-Paket. 190 var minimalWAV = []byte{ 191 'R', 'I', 'F', 'F', 44, 0, 0, 0, // ChunkSize = 4 + (8+16) + (8+8) 192 'W', 'A', 'V', 'E', 193 'f', 'm', 't', ' ', 16, 0, 0, 0, 194 1, 0, // PCM 195 1, 0, // mono 196 0x40, 0x1F, 0, 0, // 8000 Hz 197 0x80, 0x3E, 0, 0, // ByteRate = 16000 198 2, 0, // BlockAlign 199 16, 0, // Bits 200 'd', 'a', 't', 'a', 8, 0, 0, 0, 201 0, 0, 0x10, 0x7F, 0xF0, 0x80, 0x00, 0x40, // 4 Samples 202 } 203 internal/provider/provider.go --- Go 1 package provider 2 3 import ( 4 "context" 5 "errors" 6 "fmt" 7 "sort" 8 "strings" 9 ) 10 11 // DefaultName ist der Provider, den die Kette nutzt, wenn kein --provider 12 // gepinnt ist. Task 02: osbuiltin; Task 03 ersetzt den Default durch die 13 // Fallback-Kette (ElevenLabs → OpenAI → Edge → osbuiltin). 14 const DefaultName = "osbuiltin" 15 16 // ErrNoByteOutput meldet, dass die Plattform keine über Synthesize 17 // beziehbaren Audio-Bytes liefern kann — konkret Linux mit speech-dispatcher 18 // (spd-say) ohne espeak: spd-say kann Audio nur selbst über den Daemon 19 // abspielen, nicht in einen Prozess schreiben. Aufrufer sollen auf 20 // DirectSpeaker ausweichen (siehe dort). 21 var ErrNoByteOutput = errors.New( 22 "diese Plattform kann keine Audio-Bytes liefern (Linux ohne espeak; spd-say spielt nur direkt)") 23 24 // Provider ist der Vertrag aller TTS-Backends von talk. 25 // 26 // Synthesize liefert dekodierbare Audio-Bytes; das Format ist von der 27 // Implementierung dokumentiert (osbuiltin: RIFF/WAVE mit PCM — von allen 28 // Sinks der Aufgabe abspielbar; Cloud-Provider ab Task 03: mp3). 29 type Provider interface { 30 // Name liefert den eindeutigen Providernamen (CLI --provider, Config). 31 Name() string 32 33 // Available meldet, ob der Provider auf diesem System überhaupt nutzbar 34 // ist (z. B. Binary oder Key vorhanden). Muss billig und ohne Neben- 35 // wirkungen sein; false heißt: Kette soll übersprungen werden. 36 Available(ctx context.Context) bool 37 38 // Synthesize erzeugt Audio-Bytes für text in der Sprache lang. 39 Synthesize(ctx context.Context, text, lang string) ([]byte, error) 40 } 41 42 // DirectSpeaker ist eine dokumentierte optionale Erweiterung über das 43 // Provider-Interface hinaus (kein Bestandteil des Basisvertrags): Provider, 44 // die Text ohne Umweg über Synthesize-Bytes selbst hörbar ausgeben können. 45 // Notwendig auf Linux-Systemen, deren OS-TTS (speech-dispatcher) keine 46 // Audio-Bytes an Prozesse liefern kann — dort spielt osbuiltin.SpeakDirect 47 // über spd-say selbst ab (blockierend bis zum Ende der Ansage). 48 // 49 // Task 03+ muss diese Erweiterung beachten: In der Kette ist SpeakDirect 50 // die letzte Stufe, wenn kein Provider Bytes liefern kann. 51 type DirectSpeaker interface { 52 // SpeakDirect gibt text in der Sprache lang direkt über das OS-TTS aus 53 // und blockiert bis zum Ende der Wiedergabe (oder bis ctx abgebrochen 54 // wird). 55 SpeakDirect(ctx context.Context, text, lang string) error 56 } 57 58 // registry bildet Providernamen auf Konstruktoren; Task 03 ergänzt hier 59 // elevenlabs, openai und edge. 60 var registry = map[string]func() Provider{ 61 "osbuiltin": func() Provider { return OSBuiltin{} }, 62 } 63 64 // Names liefert die registrierten Providernamen (sortiert). 65 func Names() []string { 66 names := make([]string, 0, len(registry)) 67 for name := range registry { 68 names = append(names, name) 69 } 70 sort.Strings(names) 71 return names 72 } 73 74 // New liefert den Provider mit dem angegebenen Namen; "" wählt den Default. 75 // Unbekannte (oder in diesem Stand noch nicht implementierte) Namen sind 76 // ein klarer Fehler. 77 func New(name string) (Provider, error) { 78 if name == "" { 79 name = DefaultName 80 } 81 ctor, ok := registry[name] 82 if !ok { 83 return nil, fmt.Errorf("unbekannter oder noch nicht implementierter Provider %q (in diesem Stand verfügbar: %s)", 84 name, strings.Join(Names(), ", ")) 85 } 86 return ctor(), nil 87 } 88