Files
talk/.superpowers/sdd/plan/review-fa4f193..8306c6b.diff
T

2452 lines
105 KiB
Diff
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 <Befehl> ..
.. --help` .. // Beschreibung fü
.. .. r `talk <Befehl> --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 <version>` ... nsausgabe: `talk version <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.<provider>.<la . und Env sind injizierbar (Tests).
. ng> .
5 // und Provider-Endpunkte (Fake-bar f 5 // Voice-Mapping voice.<provider>.<la
. ür Tests). Implementierung folgt in . ng> 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 <lang>) 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 <lang>) 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