2451 lines
105 KiB
Diff
2451 lines
105 KiB
Diff
# 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
|
|||
|
|
|