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

2451 lines
105 KiB
Diff
Raw Normal View History

# 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