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