Files
talk/.superpowers/sdd/plan/review-ae9f3b6..8351d40.diff
T

3739 lines
173 KiB
Diff
Raw Normal View History

# Review package: ae9f3b6..HEAD
## Commits
8351d40 docs(readme): Provider-Kette, Keys/Endpunkte und Voice-Mapping dokumentieren
28b4e55 feat(cli): say-Pfad über die Provider-Kette
91cd0ec feat(provider): Cloud-Provider und Vier-Stufen-Fallback-Kette
88eddc6 feat(config): Provider-Pin, Provider-Endpunkte und Voice-Mapping
7a843ae feat(audio): mp3 dekodieren für Cloud-Provider (reiner Go-Dekoder)
## Files changed
README.md | 43 +++-
cmd/talk/cli.go | 38 +++-
cmd/talk/say.go | 82 ++++----
cmd/talk/say_test.go | 186 +++++++++++++++++-
go.mod | 2 +
go.sum | 6 +
internal/audio/audio.go | 3 +-
internal/audio/doc.go | 6 +-
internal/audio/mp3.go | 68 +++++++
internal/audio/mp3_test.go | 127 ++++++++++++
internal/audio/oto.go | 5 +-
internal/config/config.go | 36 +++-
internal/config/config_test.go | 84 ++++++++
internal/provider/chain.go | 269 +++++++++++++++++++++++++
internal/provider/chain_test.go | 342 ++++++++++++++++++++++++++++++++
internal/provider/doc.go | 16 +-
internal/provider/edge.go | 372 +++++++++++++++++++++++++++++++++++
internal/provider/edge_test.go | 321 ++++++++++++++++++++++++++++++
internal/provider/edgedrm.go | 303 ++++++++++++++++++++++++++++
internal/provider/elevenlabs.go | 112 +++++++++++
internal/provider/elevenlabs_test.go | 204 +++++++++++++++++++
internal/provider/httpclient.go | 70 +++++++
internal/provider/openai.go | 106 ++++++++++
internal/provider/openai_test.go | 164 +++++++++++++++
internal/provider/osbuiltin_test.go | 18 +-
internal/provider/provider.go | 56 ++++--
internal/provider/voices.go | 76 +++++++
internal/provider/voices_test.go | 32 +++
28 files changed, 3059 insertions(+), 88 deletions(-)
## Diff
README.md --- Text
29 `--voice`, `--lang`, `--provider`. 29 `--voice`, `--lang`, `--provider`.
30 30
31 **Hinweis:** In diesem Stand ist `sa 31 **Hinweis:** In diesem Stand ist `sa
.. y` (und der Default-Aufruf .. y` (und der Default-Aufruf
32 `talk "Text"`) implementiert — die e 32 `talk "Text"`) implementiert — inklu
.. rste Ansage über OS-TTS (blockierend .. sive der kompletten Provider-Kette
.. ; ..
.. 33 (ElevenLabs → OpenAI → Edge-TTS → OS
.. .. -Stimme, blockierend;
33 Daemon-Anbindung folgt). Alle andere 34 Daemon-Anbindung folgt). Alle andere
.. n Befehle sind Stubs und melden .. n Befehle sind Stubs und melden
34 `not implemented yet` (Exit-Code ≠ 0 35 `not implemented yet` (Exit-Code ≠ 0
.. ). .. ).
35 36
.. 37 ### Provider-Kette (Task 03)
.. 38
.. 39 Ohne Pin läuft die Vier-Stufen-Fallb
.. .. ack-Kette
.. 40 **ElevenLabs → OpenAI TTS → Edge-TTS
.. .. → OS built-in**: Bei fehlendem Key,
.. 41 HTTP-Fehler, Timeout oder Netz-Probl
.. .. em fällt die Kette automatisch auf d
.. .. ie
.. 42 nächste Stufe; jeder Fallback wird a
.. .. uf stderr protokolliert (nie still).
.. 43 Pinnen per `--provider=<name>` oder
.. .. `provider = "<name>"` in der
.. 44 config.toml.
.. 45
.. 46 | Stufe | Key (Env) | Default-Endpun
.. .. kt | Audio |
.. 47 | --- | --- | --- | --- |
.. 48 | `elevenlabs` | `TALK_ELEVENLABS_KE
.. .. Y` | `https://api.elevenlabs.io` | m
.. .. p3 |
.. 49 | `openai` | `TALK_OPENAI_KEY` | `ht
.. .. tps://api.openai.com` | mp3 |
.. 50 | `edge` | — (kostenlos) | `wss://sp
.. .. eech.platform.bing.com/...` | mp3 |
.. 51 | `osbuiltin` | — | lokale OS-TTS |
.. .. WAV/PCM |
.. 52
.. 53 Keys laufen ausschließlich über Env-
.. .. Variablen (nie in die config.toml).
.. 54 Endpunkte sind konfigurierbar — Date
.. .. i `[providers.<name>] endpoint = "…"
.. .. `
.. 55 bzw. Env `TALK_ELEVENLABS_ENDPOINT`
.. .. / `TALK_OPENAI_ENDPOINT` /
.. 56 `TALK_EDGE_ENDPOINT` (Env schlägt Da
.. .. tei; so zeigen Tests/CI die Kette au
.. .. f
.. 57 Fake-Server). Das OpenAI-Modell ist
.. .. konfigurierbar
.. 58 (`[providers.openai] model`, Default
.. .. `tts-1`).
.. 59
.. 60 Stimmen: neutralen Repo-Defaults je
.. .. Provider+Sprache (ElevenLabs
.. 61 „Rachel", OpenAI „alloy", Edge `de-D
.. .. E-KatjaNeural`/`en-US-AriaNeural`)
.. 62 kann man pro Sprache überordnen — co
.. .. nfig.toml:
.. 63
.. 64 ```toml
.. 65 [voice.elevenlabs]
.. 66 de = "21m00Tcm4TlvDq8ikWAM" # eige
.. .. ne Stimme je Sprache
.. 67 en = "21m00Tcm4TlvDq8ikWAM"
.. 68
.. 69 [voice.edge]
.. 70 de = "de-DE-KatjaNeural"
.. 71 ```
.. 72
.. 73 Rangfolge: `--voice`-Flag > `voice.<
.. .. provider>.<lang>` > Repo-Default.
.. 74 MP3 wird von der Binärdatei selbst d
.. .. ekodiert (reiner Go-Dekoder, kein
.. 75 ffmpeg, CGO-frei).
.. 76
36 ### OS-TTS auf Linux (Dev-Abhängigke 77 ### OS-TTS auf Linux (Dev-Abhängigke
.. it espeak) .. it espeak)
37 78
38 Der OS-Provider `osbuiltin` nutzt au 79 Der OS-Provider `osbuiltin` nutzt au
.. f Linux zwei Wege: .. f Linux zwei Wege:
cmd/talk/cli.go --- 1/5 --- Go
145 145 return runDefault(argv, stdin, stdout, stderr)
146 146 }
... 147
... 148 // parseInterspersed parst Flags, die überall in argv stehen dürfen. Die
... 149 // Go-stdlib-flag stoppt beim ersten Nicht-Flag-AArgument; die CLI-Surface
... 150 // verlangt aber `talk "Text" --provider=x` (Abnahmekriterium Task 03)
... 151 // genauso wie `talk --provider=x "Text"`. Trick: Parse über den Rest
... 152 // wiederholen und jeweils das Positionsargument vor dem Weiterparsen
... 153 // entnehmen. Fehler (unbekannte Flags, -h) kommen wie gewohnt vom FlagSet.
... 154 func parseInterspersed(fs *flag.FlagSet, argv []string) ([]string, error) {
... 155 var positional []string
... 156 rest := argv
... 157 for {
... 158 if err := fs.Parse(rest); err != nil {
... 159 return nil, err
... 160 }
... 161 args := fs.Args()
... 162 if len(args) == 0 {
... 163 return positional, nil
... 164 }
... 165 // args[0] ist das Argument, an dem Parse gestoppt hat.
... 166 positional = append(positional, args[0])
... 167 rest = args[1:]
... 168 }
... 169 }
147 170
148 171 func lookupCommand(name string) (command, bool) {
149 172 for _, cmd := range commands {
cmd/talk/cli.go --- 2/5 --- Go
171 } 194 }
172 195
173 // runCommand führt einen benannten 196 // runCommand führt einen benannten
... Subcommand aus. say ist implementi ... Subcommand aus. say ist implementi
... ert ... ert
174 // (Task 02); alle anderen sind Stu 197 // (Task 02); alle anderen sind Stu
... bs bis zu ihren Tasks. ... bs bis zu ihren Tasks. Flags dürfen
... ... auch
... 198 // nach dem Text stehen (parseInter
... ... spersed).
175 func runCommand(cmd command, args [ 199 func runCommand(cmd command, args [
... ]string, stdin io.Reader, stdout, s ... ]string, stdin io.Reader, stdout, s
... tderr io.Writer) int { ... tderr io.Writer) int {
176 fs, help, sayOpts := newCommand 200 fs, help, sayOpts := newCommand
... FlagSet(cmd, stderr) ... FlagSet(cmd, stderr)
... 201 positional, err := parseIntersp
... ... ersed(fs, args)
177 if err := fs.Parse(args); err ! 202 if err != nil {
... = nil { ...
178 if errors.Is(err, flag.ErrH 203 if errors.Is(err, flag.ErrH
... elp) { // z. B. nacktes -h ... elp) { // z. B. nacktes -h
179 writeCommandHelp(cmd, s 204 writeCommandHelp(cmd, s
... tdout) ... tdout)
180 return exitOK 205 return exitOK
cmd/talk/cli.go --- 3/5 --- Go
188 return exitOK 213 return exitOK
189 } 214 }
190 if cmd.handler != nil { 215 if cmd.handler != nil {
191 return cmd.handler(fs.Args( 216 return cmd.handler(position
... ), sayOpts, stdin, stderr) ... al, sayOpts, stdin, stderr)
192 } 217 }
193 return notImplemented(cmd.name, 218 return notImplemented(cmd.name,
... stderr) ... stderr)
194 } 219 }
195 220
196 // runDefault behandelt Aufrufe ohn 221 // runDefault behandelt Aufrufe ohn
... e bekannten Subcommand: `talk "Text ... e bekannten Subcommand: `talk "Text
... "`, ... "`,
197 // `talk --version`, `talk --help` 222 // `talk --version`, `talk --help`
... sowie say-Flags vor dem Text. ... sowie say-Flags vor oder nach dem T
... ... ext.
198 func runDefault(argv []string, stdi 223 func runDefault(argv []string, stdi
... n io.Reader, stdout, stderr io.Writ ... n io.Reader, stdout, stderr io.Writ
... er) int { ... er) int {
199 fs := flag.NewFlagSet("talk", f 224 fs := flag.NewFlagSet("talk", f
... lag.ContinueOnError) ... lag.ContinueOnError)
200 fs.SetOutput(stderr) 225 fs.SetOutput(stderr)
cmd/talk/cli.go --- 4/5 --- Go
203 help := fs.Bool("help", false, 228 help := fs.Bool("help", false,
... "diese Hilfe anzeigen") ... "diese Hilfe anzeigen")
204 sayOpts := addSayFlags(fs) 229 sayOpts := addSayFlags(fs)
205 230
... 231 positional, err := parseIntersp
... ... ersed(fs, argv)
206 if err := fs.Parse(argv); err ! 232 if err != nil {
... = nil { ...
207 if errors.Is(err, flag.ErrH 233 if errors.Is(err, flag.ErrH
... elp) { ... elp) {
208 writeUsage(stdout) 234 writeUsage(stdout)
209 return exitOK 235 return exitOK
cmd/talk/cli.go --- 5/5 --- Go
219 writeUsage(stdout) 245 writeUsage(stdout)
220 return exitOK 246 return exitOK
221 } 247 }
222 return runSay(fs.Args(), sayOpt 248 return runSay(positional, sayOp
... s, stdin, stderr) ... ts, stdin, stderr)
223 } 249 }
224 250
225 // versionString rendert die Versio 251 // versionString rendert die Versio
... nsausgabe: `talk version <version>` ... nsausgabe: `talk version <version>`
... plus ... plus
cmd/talk/say.go --- 1/3 --- Go
2 2
3 3 import (
4 4 "context"
5 . "errors"
6 5 "fmt"
7 6 "io"
8 7 "os"
cmd/talk/say.go --- 2/3 --- Go
29 provider string 28 provider string
30 } 29 }
31 30
32 // runSay ist der komplette Ansage- 31 // runSay ist der komplette Ansage-
.. Pfad von Task 02 (direkt und blocki .. Pfad (direkt und blockierend;
.. erend; ..
33 // Daemon-Anbindung folgt in Task 0 32 // Daemon-Anbindung folgt in Task 0
.. 4): Text aus Argumenten oder stdin .. 4): Text aus Argumenten oder stdin
.. → .. →
34 // Spracherkennung → Provider (Defa 33 // Spracherkennung → Provider-Kette
.. ult osbuiltin; Kette folgt Task 03) .. (Task 03: elevenlabs → openai → ed
.. → .. ge →
.. 34 // osbuiltin; --provider/config pro
.. .. vider pinnen) → Synthese mit Fallba
.. .. ck →
35 // Synthesize → Sink.Play. Auf Plat 35 // Sink.Play. Auf Plattformen, dere
.. tformen, deren OS-TTS keine Bytes l .. n OS-TTS keine Bytes liefern kann (
.. iefern .. Linux
36 // kann (Linux ohne espeak), spring 36 // ohne espeak), springt die dokume
.. t die dokumentierte Erweiterung .. ntierte Erweiterung provider.Direct
.. .. Speaker
37 // provider.DirectSpeaker an (spd-s 37 // an (spd-say); mit audio.sink=nul
.. ay); mit audio.sink=null wird die .. l wird die Ansage stattdessen verwo
.. .. rfen —
38 // Ansage stattdessen verworfen — C 38 // CI/Server bleiben ton- und fehle
.. I/Server bleiben ton- und fehlerfre .. rfrei.
.. i. ..
39 func runSay(args []string, opts *sa 39 func runSay(args []string, opts *sa
.. yOptions, stdin io.Reader, stderr i .. yOptions, stdin io.Reader, stderr i
.. o.Writer) int { .. o.Writer) int {
40 text := strings.Join(args, " ") 40 text := strings.Join(args, " ")
41 if text == "" { 41 if text == "" {
cmd/talk/say.go --- 3/3 --- Go
63 fmt.Fprintf(stderr, "talk: 63 fmt.Fprintf(stderr, "talk:
.. --lang: ungültiger Wert %q (erlaubt .. --lang: ungültiger Wert %q (erlaubt
.. : de, en)\n", lang) .. : de, en)\n", lang)
64 return exitFailure 64 return exitFailure
65 } 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 66
70 cfg, err := config.Load(config. 67 cfg, err := config.Load(config.
.. Options{Getenv: getenv}) .. Options{Getenv: getenv})
71 if err != nil { 68 if err != nil {
72 fmt.Fprintf(stderr, "talk: 69 fmt.Fprintf(stderr, "talk:
.. %v\n", err) .. %v\n", err)
73 return exitFailure 70 return exitFailure
74 } 71 }
75 72
.. 73 // Sink zuerst: Die Kette brauc
.. .. ht ihn als Player (Bytes-Wiedergabe
.. .. ) und
.. 74 // für die null-Entscheidung be
.. .. i der Direktansage.
76 p, err := provider.New(opts.pro 75 sink, err := audio.NewSink(cfg.
.. vider) .. Audio.Sink, stderr)
77 if err != nil { 76 if err != nil {
78 fmt.Fprintf(stderr, "talk: 77 fmt.Fprintf(stderr, "talk:
.. %v\n", err) .. %v\n", err)
79 return exitFailure 78 return exitFailure
80 } 79 }
81 ..
82 ctx := context.Background() ..
83 if !p.Available(ctx) { ..
84 fmt.Fprintf(stderr, "talk: ..
.. Provider %s ist auf diesem System n ..
.. icht verfügbar (keine passende OS-T ..
.. TS-Runtime gefunden)\n", p.Name()) ..
85 return exitFailure ..
86 } ..
87 80
88 data, err := p.Synthesize(ctx, 81 chain, err := provider.NewChain
.. text, lang) .. (provider.ChainConfig{
89 if err != nil { 82 Pin: pickProvider(opt
.. .. s.provider, cfg.Provider),
90 if errors.Is(err, provider. 83 Voice: opts.voice,
.. ErrNoByteOutput) { ..
91 if cfg.Audio.Sink == co 84 Voices: cfg.Voice,
.. nfig.SinkNull { ..
92 // null-Sink verwir 85 Endpoints: endpointsOf(cfg)
.. ft die Ansage grundsätzlich — auch .. ,
.. die ..
93 // Direktansage wür ..
.. de Ton erzeugen und entfällt somit. ..
94 fmt.Fprintln(stderr ..
.. , "talk: Ansage verworfen (audio.si ..
.. nk=null)") ..
95 return exitOK ..
96 } ..
97 if ds, ok := p.(provide ..
.. r.DirectSpeaker); ok { ..
98 if err := ds.SpeakD 86 Model: cfg.Providers["o
.. irect(ctx, text, lang); err != nil .. penai"].Model,
.. { ..
99 fmt.Fprintf(std ..
.. err, "talk: %v\n", err) ..
100 return exitFail ..
... ure ..
101 } ..
102 return exitOK ..
103 } ..
104 } ..
105 fmt.Fprintf(stderr, "talk: 87 Getenv: getenv,
... %v\n", err) ..
106 return exitFailure 88 Player: sink,
... 89 Log: stderr,
107 } 90 })
108 ..
109 sink, err := audio.NewSink(cfg. ..
... Audio.Sink, stderr) ..
110 if err != nil { 91 if err != nil {
111 fmt.Fprintf(stderr, "talk: 92 fmt.Fprintf(stderr, "talk:
... %v\n", err) .. %v\n", err)
112 return exitFailure 93 return exitFailure
113 } 94 }
... 95
... 96 ctx := context.Background()
114 if err := sink.Play(ctx, data); 97 if err := chain.Speak(ctx, text
... err != nil { .. , lang); err != nil {
115 fmt.Fprintf(stderr, "talk: 98 fmt.Fprintf(stderr, "talk:
... Wiedergabe über %s fehlgeschlagen: .. %v\n", err)
... %v\n", sink.Name(), err) ..
116 return exitFailure 99 return exitFailure
117 } 100 }
118 return exitOK 101 return exitOK
119 } 102 }
... 103
... 104 // pickProvider löst den Provider-P
... ... in auf: CLI-Flag --provider schlägt
... 105 // `provider = "…"` aus der config.
... ... toml; leer = komplette Default-Kett
... ... e.
... 106 func pickProvider(flagValue, cfgVal
... ... ue string) string {
... 107 if flagValue != "" {
... 108 return flagValue
... 109 }
... 110 return cfgValue
... 111 }
... 112
... 113 // endpointsOf überführt die Provid
... ... er-Einstellungen der Config in die
... 114 // Endpunkt-Map der Kette.
... 115 func endpointsOf(cfg config.Config)
... ... map[string]string {
... 116 endpoints := make(map[string]st
... ... ring, len(cfg.Providers))
... 117 for name, settings := range cfg
... ... .Providers {
... 118 if settings.Endpoint != ""
... ... {
... 119 endpoints[name] = setti
... ... ngs.Endpoint
... 120 }
... 121 }
... 122 return endpoints
... 123 }
120 124
121 // isCharDevice meldet, ob r ein Te 125 // isCharDevice meldet, ob r ein Te
... rminal ist (dann darf talk nicht au ... rminal ist (dann darf talk nicht au
... f ... f
122 // stdin-Input warten, sondern meld 126 // stdin-Input warten, sondern meld
... et einen Benutzungsfehler). ... et einen Benutzungsfehler).
cmd/talk/say_test.go --- 1/3 --- Go
2 2
3 import ( 3 import (
4 "bytes" 4 "bytes"
. 5 "net/http"
. 6 "net/http/httptest"
5 "os" 7 "os"
6 "path/filepath" 8 "path/filepath"
7 "strings" 9 "strings"
8 "testing" 10 "testing"
9 ) 11 )
10 12
11 // setGetenv überschreibt die Env-S 13 // setGetenv überschreibt die Env-S
.. eam der CLI für einen Test. .. eam der CLI für einen Test. Grundau
.. .. s-
.. 14 // stattung jedes Tests: TALK_EDGE_
.. .. ENDPOINT zeigt auf einen garantiert
.. 15 // toten Loopback-Port (Verbindung
.. .. wird sofort abgelehnt) — die Defaul
.. .. t-
.. 16 // Kette enthält edge, und kein Tes
.. .. t darf je den echten Microsoft-Endp
.. .. unkt
.. 17 // anfassen. Wer edge bewusst ander
.. .. s braucht, überschreibt den Key.
12 func setGetenv(t *testing.T, kv map 18 func setGetenv(t *testing.T, kv map
.. [string]string) { .. [string]string) {
13 t.Helper() 19 t.Helper()
.. 20 base := map[string]string{"TALK
.. .. _EDGE_ENDPOINT": "ws://127.0.0.1:1/
.. .. "}
.. 21 for k, v := range kv {
.. 22 base[k] = v
.. 23 }
14 old := getenv 24 old := getenv
15 getenv = func(k string) string 25 getenv = func(k string) string
.. { .. {
16 if v, ok := kv[k]; ok { 26 if v, ok := base[k]; ok {
17 return v 27 return v
18 } 28 }
19 return "" 29 return ""
cmd/talk/say_test.go --- 2/3 --- Go
158 } 168 }
159 } 169 }
160 170
161 // TestSayUnbekannterProvider: --pr 171 // TestSayUnbekannterProvider: --pr
... ovider mit noch nicht implementiert ... ovider mit unbekanntem Namen ist ei
... em ... n
162 // Namen ist ein klarer Fehler (Tas 172 // klarer Fehler, der die verfügbar
... k 03 ergänzt die Kette). ... en Provider nennt (Task 03: Kette).
163 func TestSayUnbekannterProvider(t * 173 func TestSayUnbekannterProvider(t *
... testing.T) { ... testing.T) {
164 withFakeEspeak(t) 174 withFakeEspeak(t)
165 setGetenv(t, map[string]string{ 175 setGetenv(t, map[string]string{
... "TALK_AUDIO_SINK": "null"}) ... "TALK_AUDIO_SINK": "null"})
166 var out, errBuf bytes.Buffer 176 var out, errBuf bytes.Buffer
167 code := runCLI([]string{"--prov 177 code := runCLI([]string{"--prov
... ider=elevenlabs", "Test"}, &out, &e ... ider=gibtsnicht", "Test"}, &out, &e
... rrBuf) ... rrBuf)
168 if code != exitFailure { 178 if code != exitFailure {
169 t.Fatalf("talk --provider=e 179 t.Fatalf("talk --provider=g
... levenlabs: Exit-Code = %d, want %d" ... ibtsnicht: Exit-Code = %d, want %d"
... , code, exitFailure) ... , code, exitFailure)
170 } 180 }
171 if !strings.Contains(errBuf.Str 181 if !strings.Contains(errBuf.Str
... ing(), "elevenlabs") || !strings.Co ... ing(), "gibtsnicht") || !strings.Co
... ntains(errBuf.String(), "osbuiltin" ... ntains(errBuf.String(), "osbuiltin"
... ) { ... ) || !strings.Contains(errBuf.Strin
... ... g(), "edge") {
172 t.Errorf("stderr = %q, will 182 t.Errorf("stderr = %q, will
... Provider-Fehler mit verfügbaren Na ... Provider-Fehler mit verfügbaren Na
... men", errBuf.String()) ... men", errBuf.String())
173 } 183 }
174 } 184 }
cmd/talk/say_test.go --- 3/3 --- Go
251 261 }
252 262 }
263
264 // fakeCloudServer startet einen httptest-HTTP-Fake, der status mit body
265 // antwortet und den letzten Request aufzeichnet (Struktur wie im
266 // provider-Paket; die CLI-Tests beobachten nur extern: Exit-Code, stderr,
267 // ob der Fake gerufen wurde und mit welchem Pfad).
268 type fakeCloudReq struct {
269 path string
270 apiKey string
271 }
272
273 func fakeCloudServer(t *testing.T, status int, body []byte) (url string, reqs chan fakeCloudReq) {
274 t.Helper()
275 reqs = make(chan fakeCloudReq, 8)
276 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
277 reqs <- fakeCloudReq{path: r.URL.Path, apiKey: r.Header.Get("xi-api-key")}
278 w.WriteHeader(status)
279 w.Write(body)
280 }))
281 t.Cleanup(ts.Close)
282 return ts.URL, reqs
283 }
284
285 // TestSayCloudProviderMitFake: `talk "Text" --provider=elevenlabs` mit Key
286 // + Fake-Endpunkt und sink=null läuft mit Exit 0 durch — Synthese lief
287 // (Fake gerufen, korrekter Pfad inkl. neutraler Default-Stimme, Key im
288 // Header), Wiedergabe verworfen.
289 func TestSayCloudProviderMitFake(t *testing.T) {
290 url, reqs := fakeCloudServer(t, 200, []byte("\xff\xfb\x90\x00mp3"))
291 setGetenv(t, map[string]string{
292 "TALK_AUDIO_SINK": "null",
293 "TALK_ELEVENLABS_KEY": "cli-testkey",
294 "TALK_ELEVENLABS_ENDPOINT": url,
295 })
296
297 var out, errBuf bytes.Buffer
298 code := runCLI([]string{"--provider=elevenlabs", "Test"}, &out, &errBuf)
299 if code != exitOK {
300 t.Fatalf("talk --provider=elevenlabs (Fake): Exit-Code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
301 }
302 select {
303 case req := <-reqs:
304 if want := "/v1/text-to-speech/21m00Tcm4TlvDq8ikWAM"; req.path != want {
305 t.Errorf("Pfad = %q, want %q (neutrales Default)", req.path, want)
306 }
307 if req.apiKey != "cli-testkey" {
308 t.Errorf("xi-api-key = %q", req.apiKey)
309 }
310 default:
311 t.Error("Fake-Endpunkt wurde nicht gerufen")
312 }
313 if !strings.Contains(errBuf.String(), "verworfen (null sink)") {
314 t.Errorf("stderr = %q, will Verwurf-Zeile", errBuf.String())
315 }
316 }
317
318 // TestSayVoiceFlagAmCloudProvider: --voice landet im Request-Pfad.
319 func TestSayVoiceFlagAmCloudProvider(t *testing.T) {
320 url, reqs := fakeCloudServer(t, 200, []byte("\xff\xfb\x90\x00mp3"))
321 setGetenv(t, map[string]string{
322 "TALK_AUDIO_SINK": "null",
323 "TALK_ELEVENLABS_KEY": "k",
324 "TALK_ELEVENLABS_ENDPOINT": url,
325 })
326
327 var out, errBuf bytes.Buffer
328 if code := runCLI([]string{"--provider=elevenlabs", "--voice=MeineStimme", "Test"}, &out, &errBuf); code != exitOK {
329 t.Fatalf("Exit-Code = %d, stderr = %q", code, errBuf.String())
330 }
331 select {
332 case req := <-reqs:
333 if want := "/v1/text-to-speech/MeineStimme"; req.path != want {
334 t.Errorf("Pfad = %q, want %q", req.path, want)
335 }
336 default:
337 t.Error("Fake-Endpunkt wurde nicht gerufen")
338 }
339 }
340
341 // TestSayCloudOhneKey: gepinnter Cloud-Provider ohne Key → klarer Fehler
342 // (Stufe nicht verfügbar, Exit 1).
343 func TestSayCloudOhneKey(t *testing.T) {
344 url, _ := fakeCloudServer(t, 200, []byte("x"))
345 setGetenv(t, map[string]string{
346 "TALK_AUDIO_SINK": "null",
347 "TALK_ELEVENLABS_ENDPOINT": url, // Endpunkt, aber kein Key
348 })
349
350 var out, errBuf bytes.Buffer
351 code := runCLI([]string{"--provider=elevenlabs", "Test"}, &out, &errBuf)
352 if code != exitFailure {
353 t.Fatalf("Exit-Code = %d, want %d", code, exitFailure)
354 }
355 if !strings.Contains(errBuf.String(), "nicht verfügbar") {
356 t.Errorf("stderr = %q, will Verfügbarkeits-Hinweis", errBuf.String())
357 }
358 }
359
360 // TestSayCloud500FaelltAufOsbuiltin: gepinnt wäre kurzfristig — hier ohne
361 // Pin: elevenlabs (Fake, 500) → openai (kein Key) → edge (tot) → osbuiltin
362 // (fake espeak) liefert die Bytes. Der CLI-Ketten-Fallback ist end-to-end
363 // über die Env-Seam beobachtbar.
364 func TestSayCloud500FaelltAufOsbuiltin(t *testing.T) {
365 url, _ := fakeCloudServer(t, 500, []byte("boom"))
366 seen := withFakeEspeak(t)
367 setGetenv(t, map[string]string{
368 "TALK_AUDIO_SINK": "null",
369 "TALK_ELEVENLABS_KEY": "k",
370 "TALK_ELEVENLABS_ENDPOINT": url,
371 })
372
373 var out, errBuf bytes.Buffer
374 code := runCLI([]string{"Ketten Test"}, &out, &errBuf)
375 if code != exitOK {
376 t.Fatalf("talk mit 500-Cloud-Stufe: Exit-Code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
377 }
378 if _, err := os.Stat(seen); err != nil {
379 t.Errorf("fake espeak nicht gerufen (Kette nicht bis osbuiltin gefallen): %v", err)
380 }
381 if !strings.Contains(errBuf.String(), "500") || !strings.Contains(errBuf.String(), "Fallback") {
382 t.Errorf("stderr = %q, will sichtbaren 500-Fallback", errBuf.String())
383 }
384 }
385
386 // TestSayFlagNachDemText: `talk "Text" --provider=…` (Flag NACH dem Text,
387 // Abnahme-Aufrufform) pinnt den Provider — nur die gepinnte Stufe wird
388 // angefragt.
389 func TestSayFlagNachDemText(t *testing.T) {
390 url, reqs := fakeCloudServer(t, 200, []byte("\xff\xfb\x90\x00mp3"))
391 setGetenv(t, map[string]string{
392 "TALK_AUDIO_SINK": "null",
393 "TALK_ELEVENLABS_KEY": "k",
394 "TALK_ELEVENLABS_ENDPOINT": url,
395 })
396
397 var out, errBuf bytes.Buffer
398 code := runCLI([]string{"Test ansage", "--provider=elevenlabs"}, &out, &errBuf)
399 if code != exitOK {
400 t.Fatalf("talk \"Text\" --provider=elevenlabs: Exit-Code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
401 }
402 select {
403 case req := <-reqs:
404 if !strings.Contains(req.path, "/v1/text-to-speech/") {
405 t.Errorf("Pfad = %q, want TTS-Pfad", req.path)
406 }
407 default:
408 t.Error("Fake-Endpunkt wurde nicht gerufen")
409 }
410 // Zwei Argumente mit verteilten Flags: Text bleibt vollständig.
411 seen := withFakeEspeak(t)
412 setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"})
413 code = runCLI([]string{"Erster", "--lang=en", "zweiter Teil"}, &out, &errBuf)
414 if code != exitOK {
415 t.Fatalf("talk Erster --lang=en zweiter Teil: Exit-Code = %d, stderr = %q", code, errBuf.String())
416 }
417 args, err := os.ReadFile(seen)
418 if err != nil {
419 t.Fatalf("fake espeak nicht gerufen: %v", err)
420 }
421 if got := strings.TrimSpace(string(args)); got != "-v en --stdout Erster zweiter Teil" {
422 t.Errorf("espeak ARGS = %q, want %q", got, "-v en --stdout Erster zweiter Teil")
423 }
424 }
go.mod --- Text
5 5 require (
6 6 github.com/BurntSushi/toml v1.6.0
7 7 github.com/ebitengine/oto/v3 v3.5.0
. 8 github.com/gorilla/websocket v1.5.3
. 9 github.com/hajimehoshi/go-mp3 v0.3.4
8 10 )
9 11
10 12 require (
go.sum --- Text
4 4 github.com/ebitengine/oto/v3 v3.5.0/go.mod h1:Elkm7yzTRns3w2efvibzVOoQ65YOwmec9a76dCiK10o=
5 5 github.com/ebitengine/purego v0.11.0 h1:jhp/D+Nyv7UUW8HAcmcjt2N2rYrYi9m3SL21k0Ua/NI=
6 6 github.com/ebitengine/purego v0.11.0/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ=
. 7 github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
. 8 github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
. 9 github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68=
. 10 github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo=
. 11 github.com/hajimehoshi/oto/v2 v2.3.1/go.mod h1:seWLbgHH7AyUMYKfKYT9pg7PhUu9/SisyJvNTT+ASQo=
7 12 github.com/jfreymuth/pulse v0.1.3 h1:bc5TdxiB8E+2INnFjFWWgyfgXtz2IyNNNCX+Wt/ZD14=
8 13 github.com/jfreymuth/pulse v0.1.3/go.mod h1:cpYspI6YljhkUf1WLXLLDmeaaPFc3CnGLjDZf9dZ4no=
. 14 golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
9 15 golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
10 16 golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
internal/audio/audio.go --- Go
13 ) 13 )
14 14
15 // Sink gibt Audio-Bytes wieder. Die 15 // Sink gibt Audio-Bytes wieder. Die
.. Bytes stammen aus Provider.Synthesi .. Bytes stammen aus Provider.Synthesi
.. ze; .. ze;
16 // oto versteht RIFF/WAVE (PCM) — da 16 // oto versteht RIFF/WAVE (PCM — das
.. s Lieferformat von osbuiltin. .. Lieferformat von osbuiltin) und MP3
.. 17 // (das Lieferformat der Cloud-Provi
.. .. der ElevenLabs/OpenAI/Edge).
17 type Sink interface { 18 type Sink interface {
18 // Play spielt die Daten blockie 19 // Play spielt die Daten blockie
.. rend ab (bis zum Ende oder bis ctx .. rend ab (bis zum Ende oder bis ctx
19 // abgebrochen wird). 20 // abgebrochen wird).
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 (RIFF/WAVE-PCM) und ein . via oto (RIFF/WAVE-PCM und MP3, per
3 // null-Sink, das verwirft (CI, Serve 3 // reinem Go-Dekodierer) und ein null
. r, Tests). Auswahl per Config . -Sink, das verwirft (CI, Server,
4 // audio.sink = "oto" | "null" (Defau 4 // Tests). Auswahl per Config audio.s
. lt oto). . ink = "oto" | "null" (Default oto).
5 package audio 5 package audio
internal/audio/mp3.go --- Go
1 package audio
2
3 import (
4 "bytes"
5 "fmt"
6 "io"
7
8 mpg "github.com/hajimehoshi/go-mp3"
9 )
10
11 // decodeMP3 dekodiert MP3-Daten — das Lieferformat der Cloud-Provider ab
12 // Task 03 (ElevenLabs, OpenAI TTS, Edge-TTS) — in rohe PCM-Samples für
13 // oto.
14 //
15 // Entscheidung mp3-Dekodierung (Task 03): eingebauter, reiner Go-Dekoder
16 // github.com/hajimehoshi/go-mp3 v0.3.4 statt WAV/PCM-Anforderung bei den
17 // APIs. Begründung: (1) Alle drei Cloud-Provider liefern mp3 als ihr
18 // natives Format; nur OpenAI kennt response_format "wav"/"pcm", ElevenLabs
19 // und Edge-TTS aber nicht — eine einheitliche mp3-Pipeline hält die Kette
20 // symmetrisch. (2) Der Dekoder ist nachweislich frei von CGO (kein
21 // import "C" im Modul), damit bleibt die Cross-Compile-Verpflichtung
22 // (CGO_ENABLED=0 für alle 6 Targets) unangetastet — verifiziert per
23 // GOOS/GOARCH-Matrix-Build, siehe Task-03-Report.
24 //
25 // Der Dekoder liefert immer 16-Bit-PCM little-endian mit 2 Kanälen — auch
26 // für Mono-Quellen (etwa Edge-TTS: 24 kHz mono) wird auf Stereo
27 // gespiegelt; die Sample-Rate stammt aus dem ersten MP3-Frame.
28 func decodeMP3(data []byte) (pcmFormat, []byte, error) {
29 dec, err := mpg.NewDecoder(bytes.NewReader(data))
30 if err != nil {
31 return pcmFormat{}, nil, fmt.Errorf("mp3: %w", err)
32 }
33 pcm, err := io.ReadAll(dec)
34 if err != nil {
35 return pcmFormat{}, nil, fmt.Errorf("mp3: dekodieren: %w", err)
36 }
37 if len(pcm) == 0 {
38 return pcmFormat{}, nil, fmt.Errorf("mp3: Dekodierung lieferte keine Samples")
39 }
40 return pcmFormat{
41 SampleRate: dec.SampleRate(),
42 Channels: 2, // go-mp3 dekodiert grundsätzlich 2 Kanäle (s. doc)
43 BitsPerCoded: 16,
44 }, pcm, nil
45 }
46
47 // looksLikeMP3 meldet, ob data nach MP3 aussieht: ID3v2-Tag (führt mit
48 // "ID3") oder ein roher Frame-Sync (erstes Byte 0xFF, oberste 3 Bits des
49 // zweiten Bytes gesetzt).
50 func looksLikeMP3(data []byte) bool {
51 if len(data) >= 3 && string(data[0:3]) == "ID3" {
52 return true
53 }
54 return len(data) >= 2 && data[0] == 0xFF && data[1]&0xE0 == 0xE0
55 }
56
57 // decodeAudio ist die Format-Weiche der Sinks: RIFF/WAVE (PCM) nach
58 // decodeWAV, MP3 (ID3-Tag oder Frame-Sync) nach decodeMP3. Alles andere
59 // fällt durch zu decodeWAV, das den präzisen „kein RIFF"-Fehler liefert.
60 func decodeAudio(data []byte) (pcmFormat, []byte, error) {
61 if len(data) >= 4 && string(data[0:4]) == "RIFF" {
62 return decodeWAV(data)
63 }
64 if looksLikeMP3(data) {
65 return decodeMP3(data)
66 }
67 return decodeWAV(data)
68 }
69
internal/audio/mp3_test.go --- Go
1 package audio
2
3 import (
4 "encoding/base64"
5 "strings"
6 "testing"
7 )
8
9 // Zwei einmalig erzeugte, echte MP3-Fixtures (ffmpeg am Dev-Host, in den
10 // Test eingebettet — Tests bleiben zur Laufzeit frei von Werkzeug-Abhängig-
11 // keiten). Beide entsprechen dem Lieferformat der Cloud-Provider aus
12 // Task 03:
13 //
14 // fixtureMP3Mono: 0,15 s Stille, 24 kHz mono 48 kbit/s — exakt das
15 // Edge-TTS-Ausgabeformat (audio-24khz-48kbitrate-mono-
16 // mp3); übt den Mono→Stereo-Upsample-Pfad des Dekoders.
17 // fixtureMP3Stereo: 0,15 s 440-Hz-Sinus, 44,1 kHz stereo 64 kbit/s — das
18 // typische ElevenLabs/OpenAI-Format.
19 const fixtureMP3MonoB64 = "SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjYwLjE2LjEwMQAAAAAAAAAAAAAA//OEwAAAAAAAAAAAAEluZm8AAAAPAAAACQAABdAAOTk5OTk5OTk5OTlSUlJSUlJSUlJSUmtra2tra2tra2trhISEhISEhISEhIScnJycnJycnJycnLW1tbW1tbW1tbW1zs7Ozs7Ozs7Ozs7n5+fn5+fn5+fn5///////////////AAAAAExhdmM2MC4zMQAAAAAAAAAAAAAAACQD8AAAAAAAAAXQx638BQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//NkxAAAAANIAAAAAExBTUVVVVVMQU1FNC4wVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//NkxHwAAANIAAAAAFVVVVVVVVVMQU1FNC4wVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//NkxHwAAANIAAAAAFVVVVVVVVVMQU1FNC4wVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//NkxHwAAANIAAAAAFVVVVVVVVVMQU1FNC4wVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//NkxHwAAANIAAAAAFVVVVVVVVVMQU1FNC4wVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//NkxHwAAANIAAAAAFVVVVVVVVVMQU1FNC4wVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//NkxHwAAANIAAAAAFVVVVVVVVVMQU1FNC4wVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//NkxHwAAANIAAAAAFVVVVVVVVVMQU1FNC4wVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//NkxHwAAANIAAAAAFVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV"
20
21 const fixtureMP3StereoB64 = "SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjYwLjE2LjEwMQAAAAAAAAAAAAAA//tQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAASW5mbwAAAA8AAAAHAAAGhgA/Pz8/Pz8/Pz8/Pz8/P19fX19fX19fX19fX19ff39/f39/f39/f39/f3+fn5+fn5+fn5+fn5+fn5+/v7+/v7+/v7+/v7+/v9/f39/f39/f39/f39/f//////////////////8AAAAATGF2YzYwLjMxAAAAAAAAAAAAAAAAJANpAAAAAAAABoYxZN5VAAAAAAAAAAAAAAAAAAAAAP/7UGQAAAGPB1PVMCAMAAANIKAAAQtkrUe5tQAAAAA0gwAAAAAAWLlbSAEsSzOM4EAEAaEx+7b9wfPggCAIawf1AgCByXB8P4gBCDju/o8/ynn+U9/QAAB54AwKBwMBwMBQAAFQYGzUQALpOGBjRoAS+wRiXRL4oTPxwxscc7rrbANAE+BRFYRX4CwgQbQbf8QQFYCkKoNv/iJIh8KxCPf/xVIh8PiEej3+oGhKEga/6AAAApR2UEOC4IEAFJQRgMbD4SaWFOYlBOYKASCQCLL/+1JkD4jxxgdL53QADAAADSDgAAEGsCEUDftiQAAANIAAAARAoAmvdKzM8GvTr7RI79DR2VcAU20f7f//oSHMQEjMz03SSMSce03/PSzfLHvMSwKQ4NnM9RTNDwzV1MZFVqS/AHAT+e8a7q9+L/6U/+z6FQAAwGRkDDijFFTKrjabzCXGENGzGA0TxejCIBdNEYs6apxxagJ1trIcXd1/JeZ/X/b/1+i936xuCQSSghxU5gAd/mCwEoZaKkBlfBKGC2BKeoGIwBGAMJiS/APLqv/7UmQuiPG2B8bLXsiQAAANIAAAAQZcHyVMewJQAAA0gAAABHKvo6//9u//q/7Nv1ogDEBcxUkMiRzSZwwMIINMWLQbzFZAgEwK8CEMwXMKCNMZNvIM+GXrLrPfy/0dBBK8xQOM5NTeoQxNRxzhW4hOCMckxOgmjhWUztHMwPjL30xUZXRK8Ac+5n3dP8U+jp/993R/1QAAABhG0CAAXeBgIBQM7kwRQ5jJeegMk8OAwPALy4SxQUY1PL0xYN871ZmKO7X/s/1f///oT4MeHNco//tSZFAN8XoIxYt/0JAAAA0gAAABBrQhFA37YkAAADSAAAAEPXrMRAXg3c72DdEF8MRUH8+VI2Do0rU0WoxhJl9JmCf/1f//////SgAAcCBADChEw0eMWPzNYwwlh3TRv77NE4dMwhQeDIZBxRxGnjoAsl+zwbtQl9P2Vywc//29RAABYoMCCKFka6wsBjBQuObW04mcTGIbCA2hPQBoB2V2wIYzS/1q///6k////+0AAD2/T7fb/b0YCAAACXCJpXpktZdKRqMMiYljiZYRljn/+1JkdA3xqAdI6z7AmAAADSAAAAEFtCEWDXtCQAAANIAAAAS1qIWFQOGBu1BDXyALuvctJN0DKUQSBydaGrcUrx5+AghClCpXselWdBCLUOQJbMQULhJKl5UtMt63+FjfdbQTuGrApeyZav7/e/////+LP9DMeiUThj/Ihg0EmkMQAAHEiVkSKOHEQCE4SASTkRCVR6IniVx4jOxjwVce/EU7vK1MQU1FNC4wVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVf/7UmSZAPGrB8ZLfsiQAAANIAAAAQXUHTWVwAAwAAA0goAABFVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//tSZL2AA+cuVO5nIAQAAA0gwAAABJgm3HxggAAAADSDgAAEVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVU="
22
23 func fixture(t *testing.T, b64 string) []byte {
24 t.Helper()
25 data, err := base64.StdEncoding.DecodeString(b64)
26 if err != nil {
27 t.Fatalf("Fixture dekodieren: %v", err)
28 }
29 return data
30 }
31
32 // TestDecodeMP3Mono: das Edge-Format (24 kHz mono) dekodiert zu 16-Bit-PCM
33 // stereo — der Dekoder spiegelt Mono auf zwei Kanäle, Samples sind 4 Bytes
34 // (2 Kanäle × 2 Bytes) aligned.
35 func TestDecodeMP3Mono(t *testing.T) {
36 f, pcm, err := decodeMP3(fixture(t, fixtureMP3MonoB64))
37 if err != nil {
38 t.Fatalf("decodeMP3 (mono): %v", err)
39 }
40 if f.SampleRate != 24000 {
41 t.Errorf("SampleRate = %d, want 24000", f.SampleRate)
42 }
43 if f.Channels != 2 || f.BitsPerCoded != 16 || f.Float {
44 t.Errorf("Format = %+v, want 2 Kanäle/16 Bit/int", f)
45 }
46 if len(pcm) == 0 || len(pcm)%4 != 0 {
47 t.Errorf("PCM-Länge = %d, want > 0 und durch 4 teilbar (16-Bit-Samples stereo)", len(pcm))
48 }
49 }
50
51 // TestDecodeMP3Stereo: ElevenLabs/OpenAI-typisches Format (44,1 kHz stereo).
52 func TestDecodeMP3Stereo(t *testing.T) {
53 f, pcm, err := decodeMP3(fixture(t, fixtureMP3StereoB64))
54 if err != nil {
55 t.Fatalf("decodeMP3 (stereo): %v", err)
56 }
57 if f.SampleRate != 44100 || f.Channels != 2 || f.BitsPerCoded != 16 {
58 t.Errorf("Format = %+v, want 44100 Hz stereo 16 Bit", f)
59 }
60 if len(pcm) == 0 || len(pcm)%4 != 0 {
61 t.Errorf("PCM-Länge = %d, want > 0 und durch 4 teilbar", len(pcm))
62 }
63 }
64
65 // TestDecodeMP3Fehler: Daten, die nach MP3 aussehen, aber keine Frames
66 // enthalten, sind ein klarer mp3-Fehler (nicht der WAV-Fehlerpfad).
67 func TestDecodeMP3Fehler(t *testing.T) {
68 cases := []struct{ name, want string }{
69 {"Müll mit Frame-Sync", "mp3:"},
70 {"ID3-Tag ohne Frames", "mp3:"},
71 }
72 for _, tc := range cases {
73 t.Run(tc.name, func(t *testing.T) {
74 var data []byte
75 switch tc.name {
76 case "Müll mit Frame-Sync":
77 data = []byte{0xFF, 0xFB, 'k', 'e', 'i', 'n', ' ', 'm', 'p', '3'}
78 case "ID3-Tag ohne Frames":
79 data = []byte("ID3\x04\x00\x00\x00\x00\x00\x00")
80 }
81 _, _, err := decodeMP3(data)
82 if err == nil || !strings.Contains(err.Error(), tc.want) {
83 t.Errorf("decodeMP3-Fehler = %v, will Meldung mit %q", err, tc.want)
84 }
85 })
86 }
87 }
88
89 // TestLooksLikeMP3 pinnt die Format-Erkennung.
90 func TestLooksLikeMP3(t *testing.T) {
91 yes := [][]byte{
92 fixture(t, fixtureMP3StereoB64), // ID3-Tag
93 {0xFF, 0xFB, 0x00, 0x00}, // roher Frame-Sync
94 }
95 no := [][]byte{
96 []byte("RIFF....WAVE"),
97 []byte("kein audio"),
98 {0xFF},
99 {},
100 }
101 for _, d := range yes {
102 if !looksLikeMP3(d) {
103 t.Errorf("looksLikeMP3(%x) = false, want true", d[:min(4, len(d))])
104 }
105 }
106 for _, d := range no {
107 if looksLikeMP3(d) {
108 t.Errorf("looksLikeMP3(%q) = true, want false", d)
109 }
110 }
111 }
112
113 // TestDecodeAudioWeiche: RIFF geht an den WAV-Parser, MP3 an den MP3-
114 // Dekoder, alles andere fällt mit dem WAV-Fehler durch.
115 func TestDecodeAudioWeiche(t *testing.T) {
116 f, _, err := decodeAudio(buildWAV(1, 1, 22050, 16, []byte{1, 2, 3, 4}))
117 if err != nil || f.SampleRate != 22050 {
118 t.Errorf("decodeAudio(WAV) = %+v, %v — soll den WAV-Pfad nehmen", f, err)
119 }
120 f, _, err = decodeAudio(fixture(t, fixtureMP3MonoB64))
121 if err != nil || f.SampleRate != 24000 {
122 t.Errorf("decodeAudio(MP3) = %+v, %v — soll den MP3-Pfad nehmen", f, err)
123 }
124 if _, _, err = decodeAudio([]byte("kein audio")); err == nil || !strings.Contains(err.Error(), "wav:") {
125 t.Errorf("decodeAudio(Müll) = %v, will WAV-Fehler", err)
126 }
127 }
128
internal/audio/oto.go --- Go
67 return c, nil 67 return c, nil
68 } 68 }
69 69
70 // Play implementiert Sink: dekodier 70 // Play implementiert Sink: dekodier
.. t WAV (RIFF/PCM) und spielt die Samp .. t die Daten (RIFF/WAVE-PCM von osbui
.. les .. ltin
.. 71 // oder MP3 von den Cloud-Providern,
.. .. siehe mp3.go) und spielt die Sample
.. .. s
71 // blockierend ab. Fehler des Audio- 72 // blockierend ab. Fehler des Audio-
.. Geräts oder des Formats werden klar .. Geräts oder des Formats werden klar
72 // gemeldet. 73 // gemeldet.
73 func (OtoSink) Play(ctx context.Cont 74 func (OtoSink) Play(ctx context.Cont
.. ext, data []byte) error { .. ext, data []byte) error {
74 f, pcm, err := decodeWAV(data) 75 f, pcm, err := decodeAudio(data)
75 if err != nil { 76 if err != nil {
76 return err 77 return err
77 } 78 }
internal/config/config.go --- 1/3 --- Go
17 ) 17 )
18 18
19 // Config ist der effektiv geladene 19 // Config ist der effektiv geladene
.. Konfigurations-Stand von talk. Tas .. Konfigurations-Stand von talk. Tas
.. k 02 .. k 02
20 // führt nur audio.sink; spätere Ta 20 // führt audio.sink; Task 03 ergänz
.. sks ergänzen Felder (Provider-Endpu .. t den Provider-Pin, Provider-Endpun
.. nkte, .. kte
.. 21 // und das Voice-Mapping; spätere T
.. .. asks ergänzen weitere Felder (serve
.. .. r,
21 // Voice-Mapping, server, queue …), 22 // queue …), ohne die Auflösungsrei
.. ohne die Auflösungsreihenfolge zu .. henfolge zu ändern.
.. ändern. ..
22 type Config struct { 23 type Config struct {
23 Audio Audio `toml:"audio"` 24 Audio Audio `toml:"audio"`
.. 25
.. 26 // Provider pinnt die Kette auf
.. .. genau einen Provider (z. B.
.. 27 // `provider = "elevenlabs"`);
.. .. leer = komplette Default-Kette
.. 28 // (elevenlabs → openai → edge
.. .. → osbuiltin). Das CLI-Flag --provid
.. .. er
.. 29 // schlägt diesen Wert. Gültige
.. .. Namen prüft die Provider-Schicht.
.. 30 Provider string `toml:"provider
.. .. "`
.. 31
.. 32 // Providers enthält per Provid
.. .. er-Einstellungen (Endpunkt, Modell)
.. .. —
.. 33 // TOML `[providers.elevenlabs]
.. .. endpoint = "…"`. Key `providers`
.. 34 // (Mehrzahl), damit der Pin (
.. .. singular `provider`) und die
.. 35 // Einstellungs-Tabelle in TOML
.. .. nebeneinander existieren können.
.. 36 Providers map[string]ProviderSe
.. .. ttings `toml:"providers"`
.. 37
.. 38 // Voice ist das Voice-Mapping
.. .. `voice.<provider>.<lang> = id` — z.
.. .. B.
.. 39 // `[voice.elevenlabs] de = "21
.. .. m00Tcm4TlvDq8ikWAM"`. Schlägt die
.. 40 // eingebauten neutralen Defaul
.. .. ts; das CLI-Flag --voice schlägt al
.. .. les.
.. 41 Voice map[string]map[string]str
.. .. ing `toml:"voice"`
24 } 42 }
25 43
26 // Audio steuert die Audio-Ausgabe. 44 // Audio steuert die Audio-Ausgabe.
internal/config/config.go --- 2/3 --- Go
30 48 Sink string `toml:"sink"`
31 49 }
.. 50
.. 51 // ProviderSettings sind die per Provider konfigurierbaren Endpunkte und
.. 52 // Modelle (TOML `[providers.<name>]`). Leer = eingebaute Defaults; die
.. 53 // Env-Overrides TALK_ELEVENLABS_ENDPOINT / TALK_OPENAI_ENDPOINT /
.. 54 // TALK_EDGE_ENDPOINT (ausgewertet in der Provider-Schicht) schlagen die
.. 55 // Datei — Keys laufen ausschließlich über TALK_ELEVENLABS_KEY bzw.
.. 56 // TALK_OPENAI_KEY und werden nie in die Datei geschrieben.
.. 57 type ProviderSettings struct {
.. 58 Endpoint string `toml:"endpoint"`
.. 59 Model string `toml:"model"` // nur openai (Default "tts-1")
.. 60 }
32 61
33 62 // Defaults liefert die eingebaute Default-Konfiguration (unterste Schicht
34 63 // der Auflösung).
internal/config/config.go --- 3/3 --- Go
84 113 if v := getenv("TALK_AUDIO_SINK"); v != "" {
85 114 cfg.Audio.Sink = v
86 115 }
.. 116 if v := getenv("TALK_PROVIDER"); v != "" {
.. 117 cfg.Provider = v
.. 118 }
87 119 switch cfg.Audio.Sink {
88 120 case SinkOto, SinkNull:
89 121 return cfg, nil
internal/config/config_test.go --- Go
205 205 })
206 206 }
207
208 // TestLoadProviderKette deckt die Task-03-Felder ab: Provider-Pin per
209 // Datei/Env, Provider-Endpunkte/-Modelle und das Voice-Mapping.
210 func TestLoadProviderKette(t *testing.T) {
211 writeTOML := func(t *testing.T, content string) string {
212 t.Helper()
213 path := filepath.Join(t.TempDir(), "config.toml")
214 if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
215 t.Fatal(err)
216 }
217 return path
218 }
219
220 toml := `provider = "openai"
221
222 [providers.elevenlabs]
223 endpoint = "https://eleven.example.com"
224
225 [providers.openai]
226 endpoint = "https://openai.example.com"
227 model = "gpt-4o-mini-tts"
228
229 [providers.edge]
230 endpoint = "wss://edge.example.com/synth"
231
232 [voice.elevenlabs]
233 de = "MeineStimme"
234 en = "MyVoice"
235
236 [voice.edge]
237 de = "de-DE-KatjaNeural"
238 `
239
240 t.Run("TOML liefert Pin, Endpunkte und Voice-Mapping", func(t *testing.T) {
241 cfg, err := Load(Options{File: writeTOML(t, toml), Getenv: env(nil)})
242 if err != nil {
243 t.Fatalf("Load: %v", err)
244 }
245 if cfg.Provider != "openai" {
246 t.Errorf("Provider = %q, want %q", cfg.Provider, "openai")
247 }
248 if got := cfg.Providers["elevenlabs"].Endpoint; got != "https://eleven.example.com" {
249 t.Errorf("Providers[elevenlabs].Endpoint = %q", got)
250 }
251 if got := cfg.Providers["openai"].Model; got != "gpt-4o-mini-tts" {
252 t.Errorf("Providers[openai].Model = %q", got)
253 }
254 if got := cfg.Providers["edge"].Endpoint; got != "wss://edge.example.com/synth" {
255 t.Errorf("Providers[edge].Endpoint = %q", got)
256 }
257 if got := cfg.Voice["elevenlabs"]["de"]; got != "MeineStimme" {
258 t.Errorf("Voice[elevenlabs][de] = %q", got)
259 }
260 if got := cfg.Voice["elevenlabs"]["en"]; got != "MyVoice" {
261 t.Errorf("Voice[elevenlabs][en] = %q", got)
262 }
263 })
264
265 t.Run("TALK_PROVIDER schlägt Datei-Pin", func(t *testing.T) {
266 cfg, err := Load(Options{
267 File: writeTOML(t, toml),
268 Getenv: env(map[string]string{"TALK_PROVIDER": "edge"}),
269 })
270 if err != nil {
271 t.Fatalf("Load: %v", err)
272 }
273 if cfg.Provider != "edge" {
274 t.Errorf("Provider = %q, want Env-Override %q", cfg.Provider, "edge")
275 }
276 })
277
278 t.Run("ohne alles: keine Pins, keine Endpunkte", func(t *testing.T) {
279 cfg, err := Load(Options{
280 File: filepath.Join(t.TempDir(), "gibt-es-nicht.toml"),
281 Getenv: env(nil),
282 })
283 if err != nil {
284 t.Fatalf("Load: %v", err)
285 }
286 if cfg.Provider != "" || cfg.Providers != nil || cfg.Voice != nil {
287 t.Errorf("Provider-Konfiguration = %+v, will leer", cfg)
288 }
289 })
290 }
internal/provider/chain.go --- Go
1 package provider
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "io"
8 "strings"
9 )
10
11 // DefaultOrder ist die bindende Default-Fallback-Reihenfolge der Kette
12 // (Spec: ElevenLabs → OpenAI TTS → Edge-TTS → OS built-in).
13 var DefaultOrder = []string{"elevenlabs", "openai", "edge", "osbuiltin"}
14
15 // NullPlayerName ist der dokumentierte Name des verwerfenden Sinks
16 // (Spec: audio.sink = "oto" | "null"). Die Kette braucht ihn für die
17 // Entscheidung, ob eine Direktansage (DirectSpeaker) verworfen werden
18 // muss — der null-Sink darf niemals Ton erzeugen.
19 const NullPlayerName = "null"
20
21 // Player spielt Audio-Bytes ab, die die Kette geliefert hat. Der Vertrag
22 // ist absichtlich strukturell identisch zu audio.Sink (Play + Name): CLI
23 // und Daemon (Task 04) übergeben das Ergebnis von audio.NewSink(name, log)
24 // unverändert — dadurch bleibt die Kette ohne Import des audio-Pakets
25 // testbar und wiederverwendbar.
26 type Player interface {
27 Play(ctx context.Context, data []byte) error
28 Name() string
29 }
30
31 // ChainConfig ist alles, was die Kette zum Bau braucht. CLI (Task 03) und
32 // Daemon (Task 04) füllen sie aus Config + Flags; die Kette selbst liest
33 // keine Dateien und keine Prozess-Env — Getenv ist die injizierbare Seam
34 // (Tests und Daemon kommen so ohne echte Keys aus).
35 type ChainConfig struct {
36 // Pin reduziert die Kette auf genau einen Provider (CLI-Flag --provider
37 // schlägt config `provider = "…"`). Leer = DefaultOrder.
38 Pin string
39
40 // Voice ist eine explizite Voice-ID (CLI-Flag --voice); sie wird über
41 // VoicePinner an alle Stufen gepinnt und schlägt Mapping + Defaults.
42 Voice string
43
44 // Voices ist das voice.<provider>.<lang>-Mapping aus der config.toml.
45 Voices map[string]map[string]string
46
47 // Endpoints sind die Datei-Endpunkte [providers.<name>].endpoint;
48 // TALK_<NAME>_ENDPOINT (via Getenv) schlägt sie, sie schlägt die
49 // eingebauten Defaults (siehe endpointFrom).
50 Endpoints map[string]string
51
52 // Model ist [providers.openai].model (leer = "tts-1").
53 Model string
54
55 // Getenv liefert Umgebungsvariablen (Keys, Endpunkt-Overrides). Nil
56 // wird beim Bau durch eine Funktion ersetzt, die immer "" liefert.
57 Getenv func(string) string
58
59 // Player gibt erfolgreiche Synthesen aus; nil = die Kette liefert nur
60 // Bytes (Synthesize) und Speak meldet einen klaren Fehler.
61 Player Player
62
63 // Log empfängt die Fallback-Protokoll-Zeilen (CLI: stderr). Fallbacks
64 // sind nie still: Jede übersprungene oder fehlgeschlagene Stufe wird
65 // hier gemeldet — auch wenn am Ende noch eine Stufe Erfolg hat.
66 Log io.Writer
67 }
68
69 // endpointFrom löst einen Provider-Endpunkt auf:
70 // TALK_<NAME>_ENDPOINT (Env) > [providers.<name>].endpoint (Datei) >
71 // eingebauter Default. Env-Overrides in der Provider-Schicht zu lesen hält
72 // das config-Paket frei von Provider-Namen und erlaubt Prozessgrenzen-
73 // Tests ohne Config-Datei.
74 func endpointFrom(cfg ChainConfig, name, envName, def string) string {
75 if cfg.Getenv != nil {
76 if v := cfg.Getenv(envName); v != "" {
77 return v
78 }
79 }
80 if v := cfg.Endpoints[name]; v != "" {
81 return v
82 }
83 return def
84 }
85
86 // Chain ist die geordnete Fallback-Kette über Provider. Sie implementiert
87 // selbst Provider (Synthesize liefert die Bytes der ersten erfolgreichen
88 // Stufe) UND DirectSpeaker (letzter Stufen-Ausweg für Plattformen ohne
89 // Byte-fähiges TTS) — damit ist sie für CLI und Daemon (Task 04)
90 // austauschbar gegen jeden einzelnen Provider.
91 //
92 // Speak ist die komplette Ansage-Orchestrierung: Bytes-Synthese mit
93 // Fallback, Wiedergabe über den Player, und als letzte Stufe die
94 // Direktansage (osbuiltin über spd-say). Bei einem null-Player wird die
95 // Direktansage tonlos verworfen (CI/Server) statt Ton zu erzeugen.
96 type Chain struct {
97 cfg ChainConfig
98 tiers []Provider
99 }
100
101 // NewChain baut die Kette: gepinnt (Pin) oder in DefaultOrder. Unbekannte
102 // Namen sind ein klarer Fehler, der die verfügbaren Namen nennt. Das
103 // --voice-Pin wird auf alle Stufen angewandt, die VoicePinner implementieren
104 // (Stufen ohne Voice-Begriff bekommen einen protokollierten Hinweis).
105 func NewChain(cfg ChainConfig) (*Chain, error) {
106 if cfg.Getenv == nil {
107 cfg.Getenv = func(string) string { return "" }
108 }
109 names := DefaultOrder
110 if cfg.Pin != "" {
111 names = []string{cfg.Pin}
112 }
113 tiers := make([]Provider, 0, len(names))
114 for _, name := range names {
115 p, err := New(name, cfg)
116 if err != nil {
117 return nil, err
118 }
119 if cfg.Voice != "" {
120 if vp, ok := p.(VoicePinner); ok {
121 vp.PinVoice(cfg.Voice)
122 } else {
123 chainLogf(cfg, "kette: --voice für Stufe %s ignoriert (kennt keine Voice-IDs)\n", name)
124 }
125 }
126 tiers = append(tiers, p)
127 }
128 return &Chain{cfg: cfg, tiers: tiers}, nil
129 }
130
131 // Name implementiert Provider.
132 func (*Chain) Name() string { return "chain" }
133
134 // Tiers liefert die Stufen in Ketten-Reihenfolge (Diagnose/doctor).
135 func (c *Chain) Tiers() []Provider { return c.tiers }
136
137 // Available implementiert Provider: true, sobald eine Stunde verfügbar ist.
138 func (c *Chain) Available(ctx context.Context) bool {
139 for _, p := range c.tiers {
140 if p.Available(ctx) {
141 return true
142 }
143 }
144 return false
145 }
146
147 // walkResult ist das interne Ergebnis des Ketten-Durchlaufs.
148 type walkResult struct {
149 data []byte // Bytes der ersten erfolgreichen Stufe (nil = keine)
150 used Provider // die erfolgreiche Stufe
151 direct DirectSpeaker // erste Stufe, die nur direkt sprechen kann
152 directName string
153 failed []string // "name: fehler" je fehlgeschlagener Stufe
154 skipped []string // Namen nicht verfügbarer Stufen
155 }
156
157 // walk läuft die Kette in Ordnung ab: nicht verfügbare Stufen werden
158 // übersprungen (protokolliert), fehlgeschlagene protokolliert und
159 // überwunden. Eine Stufe mit ErrNoByteOutput, die DirectSpeaker
160 // implementiert, wird als letzter Ausweg gemerkt (der Durchlauf geht
161 // weiter — eine spätere Stufe könnte noch Bytes liefern).
162 func (c *Chain) walk(ctx context.Context, text, lang string) walkResult {
163 var r walkResult
164 for _, p := range c.tiers {
165 if !p.Available(ctx) {
166 r.skipped = append(r.skipped, p.Name())
167 chainLogf(c.cfg, "kette: Stufe %s übersprungen (nicht verfügbar)\n", p.Name())
168 continue
169 }
170 data, err := p.Synthesize(ctx, text, lang)
171 switch {
172 case err == nil:
173 r.data, r.used = data, p
174 chainLogf(c.cfg, "kette: Ansage über %s\n", p.Name())
175 return r
176 case errors.Is(err, ErrNoByteOutput) && r.direct == nil:
177 if ds, ok := p.(DirectSpeaker); ok {
178 r.direct, r.directName = ds, p.Name()
179 chainLogf(c.cfg, "kette: Stufe %s liefert keine Bytes, kann aber direkt sprechen (als letzte Stufe gemerkt)\n", p.Name())
180 continue
181 }
182 r.failed = append(r.failed, p.Name()+": "+err.Error())
183 chainLogf(c.cfg, "kette: Stufe %s fehlgeschlagen: %v — Fallback auf nächste Stufe\n", p.Name(), err)
184 default:
185 r.failed = append(r.failed, p.Name()+": "+err.Error())
186 chainLogf(c.cfg, "kette: Stufe %s fehlgeschlagen: %v — Fallback auf nächste Stufe\n", p.Name(), err)
187 }
188 }
189 return r
190 }
191
192 // Synthesize implementiert Provider: Bytes der ersten erfolgreichen Stufe.
193 // Kann nur eine Direkt-Stufe sprechen, ist der Fehler ErrNoByteOutput (die
194 // Kette erfüllt damit den DirectSpeaker-Vertrag am Aufrufer). Scheitert
195 // alles, nennt der Fehler die letzte Stufen-Fehler — nie still.
196 func (c *Chain) Synthesize(ctx context.Context, text, lang string) ([]byte, error) {
197 r := c.walk(ctx, text, lang)
198 if r.data != nil {
199 return r.data, nil
200 }
201 if r.direct != nil {
202 if len(r.failed) > 0 {
203 return nil, fmt.Errorf("%w (Stufe %s spricht nur direkt; daneben fehlgeschlagen: %s)",
204 ErrNoByteOutput, r.directName, strings.Join(r.failed, "; "))
205 }
206 return nil, fmt.Errorf("%w (Stufe %s spricht nur direkt)", ErrNoByteOutput, r.directName)
207 }
208 return nil, fmt.Errorf("kette: keine Stufe konnte sprechen (nicht verfügbar: %s; fehlgeschlagen: %s)",
209 joinOr(r.skipped, "keine"), joinOr(r.failed, "keine"))
210 }
211
212 // SpeakDirect implementiert DirectSpeaker: Direktansage über die Stufe,
213 // die nur direkt sprechen kann (typischerweise osbuiltin/spd-say auf Linux
214 // ohne espeak). Gibt es eine Byte-fähige Stufe, ist SpeakDirect ein klarer
215 // Fehler (der Aufrufer soll Synthesize + Player nutzen).
216 func (c *Chain) SpeakDirect(ctx context.Context, text, lang string) error {
217 r := c.walk(ctx, text, lang)
218 if r.direct != nil {
219 return r.direct.SpeakDirect(ctx, text, lang)
220 }
221 if r.data != nil {
222 return fmt.Errorf("kette: Stufe %s liefert Bytes — über Synthesize und den Player abspielen, nicht direkt", r.used.Name())
223 }
224 return fmt.Errorf("kette: keine Stufe kann direkt sprechen (nicht verfügbar: %s; fehlgeschlagen: %s)",
225 joinOr(r.skipped, "keine"), joinOr(r.failed, "keine"))
226 }
227
228 // Speak ist die komplette Ansage: Synthese mit Fallback → Wiedergabe über
229 // den Player; fällt die Kette auf die Direkt-Stufe zurück, entscheidet der
230 // Player-Name über Ton (alles außer „null") oder tonloses Verwerfen (null:
231 // CI/Server bleiben fehler- und tonfrei, der Verwurf wird protokolliert).
232 // Nur ein Fehler aller Stufen oder eine fehlgeschlagene Wiedergabe sind
233 // Fehler.
234 func (c *Chain) Speak(ctx context.Context, text, lang string) error {
235 data, err := c.Synthesize(ctx, text, lang)
236 switch {
237 case err == nil:
238 if c.cfg.Player == nil {
239 return fmt.Errorf("kette: kein Player konfiguriert — Bytes können nicht abgespielt werden")
240 }
241 if err := c.cfg.Player.Play(ctx, data); err != nil {
242 return fmt.Errorf("kette: Wiedergabe über %s fehlgeschlagen: %w", c.cfg.Player.Name(), err)
243 }
244 return nil
245 case errors.Is(err, ErrNoByteOutput):
246 if c.cfg.Player != nil && c.cfg.Player.Name() == NullPlayerName {
247 chainLogf(c.cfg, "kette: Direktansage verworfen (audio.sink=null)\n")
248 return nil
249 }
250 return c.SpeakDirect(ctx, text, lang)
251 default:
252 return err
253 }
254 }
255
256 // chainLogf schreibt eine Protokoll-Zeile, wenn ein Log gesetzt ist.
257 func chainLogf(cfg ChainConfig, format string, args ...any) {
258 if cfg.Log != nil {
259 fmt.Fprintf(cfg.Log, format, args...)
260 }
261 }
262
263 // joinOr verbindet Einträge mit sep; ist die Liste leer, kommt fallback.
264 func joinOr(items []string, fallback string) string {
265 if len(items) == 0 {
266 return fallback
267 }
268 return strings.Join(items, "; ")
269 }
270
internal/provider/chain_test.go --- Go
1 package provider
2
3 import (
4 "bytes"
5 "context"
6 "errors"
7 "net/http"
8 "net/http/httptest"
9 "os"
10 "path/filepath"
11 "strings"
12 "sync/atomic"
13 "testing"
14 )
15
16 // fakePlayer zeichnet Play-Aufrufe auf (Name steuert die null-Entscheidung
17 // der Kette).
18 type fakePlayer struct {
19 name string
20 played [][]byte
21 }
22
23 func (f *fakePlayer) Play(_ context.Context, data []byte) error {
24 f.played = append(f.played, data)
25 return nil
26 }
27 func (f *fakePlayer) Name() string { return f.name }
28
29 // countingTTSServer antwortet mit status + body und zählt die Requests
30 // (atomic, der Server kann mehrere Stufen-Requests sehen).
31 func countingTTSServer(t *testing.T, status int, body []byte) (url string, hits *atomic.Int32) {
32 t.Helper()
33 var n atomic.Int32
34 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
35 n.Add(1)
36 w.WriteHeader(status)
37 w.Write(body)
38 }))
39 t.Cleanup(ts.Close)
40 return ts.URL, &n
41 }
42
43 // chainEnv baut die Getenv-Seam für Tests mit gefakten Cloud-Endpunkten.
44 func chainEnv(kv map[string]string) func(string) string {
45 return func(k string) string { return kv[k] }
46 }
47
48 // fakeProcBin schreibt ein ausführbares Shell-Skript (gleicher Mechanismus
49 // wie in osbuiltin_test.go — echter Prozess, PATH-Manipulation).
50 func fakeProcBin(t *testing.T, dir, name, body string) {
51 t.Helper()
52 script := "#!/bin/sh\n" + body + "\n"
53 if err := os.WriteFile(filepath.Join(dir, name), []byte(script), 0o755); err != nil {
54 t.Fatal(err)
55 }
56 }
57
58 // cloudEnv liefert die Env-Map für eine Kette, deren elevenlabs-Stufe auf
59 // elevenURL und deren openai-Stufe auf openaiURL zeigt (edge tot, damit
60 // kein Test den echten Microsoft-Endpunkt anfasst).
61 func cloudEnv(elevenURL, openaiURL string, extra map[string]string) map[string]string {
62 kv := map[string]string{
63 EnvElevenLabsKey: "k",
64 EnvElevenLabsEndpoint: elevenURL,
65 EnvOpenAIKey: "k",
66 EnvOpenAIEndpoint: openaiURL,
67 EnvEdgeEndpoint: "ws://127.0.0.1:1/",
68 }
69 for k, v := range extra {
70 kv[k] = v
71 }
72 return kv
73 }
74
75 // TestChainFallbackErster500ZweiterTreffer (Abnahmekriterium aus dem
76 // Task-Brief): Stufe 1 (elevenlabs) antwortet 500 → die Kette fällt auf
77 // Stufe 2 (openai) durch, deren Bytes kommen zurück; der Fallback ist im
78 // Log sichtbar, nie still.
79 func TestChainFallbackErster500ZweiterTreffer(t *testing.T) {
80 eleven, elHits := countingTTSServer(t, 500, []byte("boom"))
81 openai, oaHits := countingTTSServer(t, 200, fakeMP3)
82
83 var log bytes.Buffer
84 ch, err := NewChain(ChainConfig{
85 Getenv: chainEnv(cloudEnv(eleven, openai, nil)),
86 Log: &log,
87 })
88 if err != nil {
89 t.Fatalf("NewChain: %v", err)
90 }
91
92 data, err := ch.Synthesize(context.Background(), "Hallo Welt", "de")
93 if err != nil {
94 t.Fatalf("Synthesize: %v", err)
95 }
96 if string(data) != string(fakeMP3) {
97 t.Errorf("Synthesize = %q, want Bytes der openai-Stufe", data)
98 }
99 if elHits.Load() != 1 || oaHits.Load() != 1 {
100 t.Errorf("Hits: elevenlabs = %d, openai = %d — want je 1 (500 → Fallback)", elHits.Load(), oaHits.Load())
101 }
102 for _, want := range []string{"elevenlabs", "500", "Fallback", "Ansage über openai"} {
103 if !strings.Contains(log.String(), want) {
104 t.Errorf("Log = %q, will %q", log.String(), want)
105 }
106 }
107 }
108
109 // TestChainAllesFehlt: beide Cloud-Stufen 500, edge tot, osbuiltin nicht
110 // verfügbar → Fehler nennt die fehlgeschlagenen Stufen; Log zeigt jeden.
111 func TestChainAllesFehlt(t *testing.T) {
112 eleven, _ := countingTTSServer(t, 500, []byte("el-boom"))
113 openai, _ := countingTTSServer(t, 500, []byte("oa-boom"))
114
115 // PATH leer: osbuiltin nicht verfügbar.
116 t.Setenv("PATH", t.TempDir())
117
118 var log bytes.Buffer
119 ch, err := NewChain(ChainConfig{
120 Getenv: chainEnv(cloudEnv(eleven, openai, nil)),
121 Log: &log,
122 })
123 if err != nil {
124 t.Fatalf("NewChain: %v", err)
125 }
126
127 _, err = ch.Synthesize(context.Background(), "Hallo", "de")
128 if err == nil {
129 t.Fatal("Synthesize mit allen Stufen kaputt: kein Fehler")
130 }
131 for _, want := range []string{"elevenlabs", "openai", "osbuiltin", "500"} {
132 if !strings.Contains(err.Error(), want) && !strings.Contains(log.String(), want) {
133 t.Errorf("Weder Fehler (%v) noch Log nennen %q", err, want)
134 }
135 }
136 }
137
138 // TestChainOhneKeysEdgeOsbuiltin (Abnahmekriterium): ohne jeden Key und
139 // mit totem edge wählt die Kette osbuiltin — mit gefaktem espeak kommen
140 // dessen Bytes zurück.
141 func TestChainOhneKeysEdgeOsbuiltin(t *testing.T) {
142 dir := t.TempDir()
143 fakeProcBin(t, dir, "espeak", `printf 'RIFFxxxxWAVEfmt '`)
144 t.Setenv("PATH", dir)
145
146 var log bytes.Buffer
147 ch, err := NewChain(ChainConfig{
148 Getenv: chainEnv(map[string]string{EnvEdgeEndpoint: "ws://127.0.0.1:1/"}),
149 Log: &log,
150 })
151 if err != nil {
152 t.Fatalf("NewChain: %v", err)
153 }
154 data, err := ch.Synthesize(context.Background(), "Hallo", "de")
155 if err != nil {
156 t.Fatalf("Synthesize: %v", err)
157 }
158 if !bytes.HasPrefix(data, []byte("RIFF")) {
159 t.Errorf("Synthesize = %q, want WAV-Bytes des fake espeak", data)
160 }
161 if !strings.Contains(log.String(), "übersprungen (nicht verfügbar)") {
162 t.Errorf("Log = %q, will Skip-Hinweise für die Key-Stufen", log.String())
163 }
164 }
165
166 // TestChainPin: --provider/config pinnen die Kette auf genau eine Stufe —
167 // andere Stufen werden nicht einmal angefragt.
168 func TestChainPin(t *testing.T) {
169 eleven, elHits := countingTTSServer(t, 200, fakeMP3)
170 openai, oaHits := countingTTSServer(t, 500, []byte("boom"))
171
172 ch, err := NewChain(ChainConfig{
173 Pin: "openai",
174 Getenv: chainEnv(cloudEnv(eleven, openai, nil)),
175 })
176 if err != nil {
177 t.Fatalf("NewChain: %v", err)
178 }
179 if _, err := ch.Synthesize(context.Background(), "Hallo", "de"); err == nil {
180 t.Fatal("Gepinnte kaputte Stufe soll fehlschlagen (kein Fallback bei Pin)")
181 }
182 if elHits.Load() != 0 || oaHits.Load() != 1 {
183 t.Errorf("Hits: elevenlabs = %d (want 0), openai = %d (want 1)", elHits.Load(), oaHits.Load())
184 }
185 }
186
187 // TestChainUnbekannterPin ist ein klarer Fehler mit Namensliste.
188 func TestChainUnbekannterPin(t *testing.T) {
189 if _, err := NewChain(ChainConfig{Pin: "gibtsnicht"}); err == nil || !strings.Contains(err.Error(), "elevenlabs") {
190 t.Errorf("NewChain-Fehler = %v, will verfügbare Namen nennen", err)
191 }
192 }
193
194 // TestChainSpeakBytesUeberPlayer: erfolgreiche Synthese wird über den
195 // Player abgespielt.
196 func TestChainSpeakBytesUeberPlayer(t *testing.T) {
197 eleven, _ := countingTTSServer(t, 200, fakeMP3)
198 player := &fakePlayer{name: "oto"}
199 ch, err := NewChain(ChainConfig{
200 Getenv: chainEnv(cloudEnv(eleven, eleven, nil)),
201 Player: player,
202 })
203 if err != nil {
204 t.Fatalf("NewChain: %v", err)
205 }
206 if err := ch.Speak(context.Background(), "Hallo", "de"); err != nil {
207 t.Fatalf("Speak: %v", err)
208 }
209 if len(player.played) != 1 || string(player.played[0]) != string(fakeMP3) {
210 t.Errorf("Player.Play = %x, want die Synthese-Bytes", player.played)
211 }
212 }
213
214 // TestChainDirektMitNullPlayer: fällt die Kette auf die Direkt-Stufe
215 // (osbuiltin ohne espeak), verwirft ein null-Player die Ansage tonlos und
216 // SUCCESS — spd-say wird nicht gerufen.
217 func TestChainDirektMitNullPlayer(t *testing.T) {
218 dir := t.TempDir()
219 seen := filepath.Join(dir, "seen.txt")
220 fakeProcBin(t, dir, "spd-say", `printf '%s\n' "$*" > "`+seen+`"`)
221 t.Setenv("PATH", dir) // kein espeak → keine Bytes, aber spd-say da
222
223 var log bytes.Buffer
224 ch, err := NewChain(ChainConfig{
225 Pin: "osbuiltin",
226 Getenv: chainEnv(nil),
227 Player: &fakePlayer{name: NullPlayerName},
228 Log: &log,
229 })
230 if err != nil {
231 t.Fatalf("NewChain: %v", err)
232 }
233 if err := ch.Speak(context.Background(), "Hallo Welt", "de"); err != nil {
234 t.Fatalf("Speak (null-Player): %v", err)
235 }
236 if _, err := os.Stat(seen); err == nil {
237 t.Error("spd-say wurde gerufen — null-Player hätte verwerfen müssen")
238 }
239 if !strings.Contains(log.String(), "verworfen") {
240 t.Errorf("Log = %q, will Verwurf-Zeile", log.String())
241 }
242 }
243
244 // TestChainDirektMitTonPlayer: mit echtem Player (Name ≠ null) läuft die
245 // Direktansage über SpeakDirect — blockierend (-w) mit Sprache und Text.
246 func TestChainDirektMitTonPlayer(t *testing.T) {
247 dir := t.TempDir()
248 seen := filepath.Join(dir, "seen.txt")
249 fakeProcBin(t, dir, "spd-say", `printf '%s\n' "$*" > "`+seen+`"`)
250 t.Setenv("PATH", dir)
251
252 player := &fakePlayer{name: "oto"}
253 ch, err := NewChain(ChainConfig{
254 Pin: "osbuiltin",
255 Getenv: chainEnv(nil),
256 Player: player,
257 })
258 if err != nil {
259 t.Fatalf("NewChain: %v", err)
260 }
261 if err := ch.Speak(context.Background(), "Hallo Welt", "de"); err != nil {
262 t.Fatalf("Speak (Direkt): %v", err)
263 }
264 args, err := os.ReadFile(seen)
265 if err != nil {
266 t.Fatalf("fake spd-say nicht gerufen: %v", err)
267 }
268 if got := strings.TrimSpace(string(args)); got != "-w -l de Hallo Welt" {
269 t.Errorf("spd-say ARGS = %q, want %q", got, "-w -l de Hallo Welt")
270 }
271 if len(player.played) != 0 {
272 t.Errorf("Player.Play wurde gerufen (%x) — Direktansage geht am Player vorbei", player.played)
273 }
274 }
275
276 // TestChainSynthesizeErrNoByteOutput: Die Kette als Provider meldet
277 // ErrNoByteOutput, wenn nur eine Direkt-Stufe übrig bleibt — der
278 // DirectSpeaker-Vertrag bleibt am Aufrufer erhalten.
279 func TestChainSynthesizeErrNoByteOutput(t *testing.T) {
280 dir := t.TempDir()
281 fakeProcBin(t, dir, "spd-say", "true")
282 t.Setenv("PATH", dir)
283
284 ch, err := NewChain(ChainConfig{Pin: "osbuiltin", Getenv: chainEnv(nil)})
285 if err != nil {
286 t.Fatalf("NewChain: %v", err)
287 }
288 _, err = ch.Synthesize(context.Background(), "Hallo", "de")
289 if !errors.Is(err, ErrNoByteOutput) {
290 t.Fatalf("Synthesize-Fehler = %v, want ErrNoByteOutput", err)
291 }
292 // Chain selbst ist DirectSpeaker.
293 if _, ok := any(ch).(DirectSpeaker); !ok {
294 t.Error("Chain implementiert DirectSpeaker nicht")
295 }
296 }
297
298 // TestChainAvailableUndNamen: Available true sobald eine Stufe verfügbar;
299 // Default-Reihenfolge ist die bindende aus der Spec.
300 func TestChainAvailableUndNamen(t *testing.T) {
301 ch, err := NewChain(ChainConfig{Getenv: chainEnv(map[string]string{
302 EnvEdgeEndpoint: "wss://edge.example.com", // edge: immer verfügbar mit Endpunkt
303 })})
304 if err != nil {
305 t.Fatalf("NewChain: %v", err)
306 }
307 if !ch.Available(context.Background()) {
308 t.Error("Available = false trotz verfügbarer edge-Stufe")
309 }
310 want := []string{"elevenlabs", "openai", "edge", "osbuiltin"}
311 if len(ch.Tiers()) != len(want) {
312 t.Fatalf("Tiers = %d, want %d", len(ch.Tiers()), len(want))
313 }
314 for i, name := range want {
315 if ch.Tiers()[i].Name() != name {
316 t.Errorf("Tiers[%d] = %s, want %s", i, ch.Tiers()[i].Name(), name)
317 }
318 }
319 }
320
321 // TestChainVoicePinDurchgereicht: ChainConfig.Voice wird via VoicePinner an
322 // die Stufen gepinnt — beobachtbar am Request-Pfad des ElevenLabs-Fakes.
323 func TestChainVoicePinDurchgereicht(t *testing.T) {
324 ts, rec := fakeTTSServer(t, 200, fakeMP3)
325 ch, err := NewChain(ChainConfig{
326 Pin: "elevenlabs",
327 Voice: "ChainVoice",
328 Getenv: chainEnv(map[string]string{
329 EnvElevenLabsKey: "k",
330 EnvElevenLabsEndpoint: ts.URL,
331 }),
332 })
333 if err != nil {
334 t.Fatalf("NewChain: %v", err)
335 }
336 if _, err := ch.Synthesize(context.Background(), "Hallo", "de"); err != nil {
337 t.Fatalf("Synthesize: %v", err)
338 }
339 if want := "/v1/text-to-speech/ChainVoice"; rec.path != want {
340 t.Errorf("Pfad = %q, want %q", rec.path, want)
341 }
342 }
343
internal/provider/doc.go --- Go
1 // Package provider implementiert di 1 // Package provider implementiert di
. e TTS-Provider von talk. Der Vertrag . e TTS-Provider von talk und ihre
2 // (Name/Available/Synthesize) trägt 2 // Fallback-Kette. Der Vertrag (Name
. die Fallback-Kette aus Task 03 . /Available/Synthesize) trägt die
3 // (ElevenLabs → OpenAI TTS → Edge-T 3 // Vier-Stufen-Kette aus Task 03: El
. TS → OS built-in); die dokumentierte . evenLabs → OpenAI TTS → Edge-TTS →
. 4 // OS built-in (überschreibbar per -
. . -provider/config provider). Die
. 5 // Cloud-Provider liefern MP3 (Keys
. . ausschließlich über TALK_ELEVENLABS_
. . KEY
. 6 // bzw. TALK_OPENAI_KEY, Endpunkte k
. . onfigurierbar und damit fake-bar);
. 7 // osbuiltin liefert RIFF/WAVE-PCM.
. . Die dokumentierte optionale Erweiter
. . ung
4 // optionale Erweiterung DirectSpeak 8 // DirectSpeaker deckt Plattformen a
. er deckt Plattformen ab, deren OS-TT . b, deren OS-TTS keine Audio-Bytes
. S .
5 // keine Audio-Bytes liefern kann (L 9 // liefern kann (Linux speech-dispat
. inux speech-dispatcher). . cher) — Chain.Speak behandelt diesen
. 10 // Fall inklusive tonlosem Verwerfen
. .. am null-Sink, damit CLI (Task 03) u
. .. nd
. 11 // Daemon (Task 04) dieselbe Kette u
. .. nverändert nutzen.
6 package provider 12 package provider
internal/provider/edge.go --- Go
1 package provider
2
3 import (
4 "bytes"
5 "context"
6 "crypto/rand"
7 "encoding/binary"
8 "encoding/hex"
9 "fmt"
10 "net/http"
11 "net/url"
12 "strings"
13 "sync/atomic"
14 "time"
15
16 "github.com/gorilla/websocket"
17 )
18
19 // Edge-TTS-Konstanten. Das Protokoll ist das bekannte, von edge-tts
20 // reverse-engineerte „Read Aloud"-Protokoll von Microsoft Edge — Stand der
21 // Implementierung (2026): DRM-Token Sec-MS-GEC v2 (CS64 + verschleierte
22 // SHA-256-Variante, siehe edgedrm.go) + MUID-Cookie, im Übrigen das
23 // bekannte WebSocket-Nachrichtenformat (speech.config + SSML, Binär-Rahmen
24 // mit 2-Byte-Header-Länge, turn.end). Ändert Microsoft es, liefern die
25 // Synthese-Pfade klare Fehler mit Hinweis auf Protokoll-Änderung statt
26 // stiller Fehlfunktion.
27 const (
28 // DefaultEndpointEdge ist der produktive WebSocket-Endpunkt (Pfad ohne
29 // Query — Token/ConnectionId/Sec-MS-GEC werden in requestURL ergänzt).
30 DefaultEndpointEdge = "wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1"
31
32 // EnvEdgeEndpoint überschreibt den Endpunkt (Tests/Firmenserver).
33 EnvEdgeEndpoint = "TALK_EDGE_ENDPOINT"
34
35 edgeTrustedClientToken = "6A5AA1D4EAFF4E9FB37E23D68491D6F4"
36 edgeSecMSGECVersion = "2-143.0.3650.75"
37 edgeOrigin = "chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold"
38 edgeUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0"
39 edgeOutputFormat = "audio-24khz-48kbitrate-mono-mp3" // mp3!
40 )
41
42 // edgeIOTimeout begrenzt einzelne Lese-/Schreiboperationen sowie (wenn der
43 // Aufrufer keine Deadline mitgibt) die gesamte Synthese.
44 const edgeIOTimeout = 20 * time.Second
45
46 // edgeClockSkew korrigiert lokale Uhrenabweichung: Bei 403 liest die Kette
47 // das Date-Header-Feld der Antwort und justiert die Tick-Berechnung (ein
48 // Wiederholungsversuch, wie die edge-tts-Referenz).
49 var edgeClockSkew atomic.Int64
50
51 // Edge ist die kostenlose dritte Stufe der Kette: Microsofts „Read Aloud"-
52 // Endpoint, kein Key nötig. Synthesize öffnet eine WebSocket-Verbindung,
53 // sendet speech.config + SSML-Anfrage und sammelt die Binär-Audio-Frames
54 // bis turn.end. Rückgabeformat: MP3 (24 kHz mono — der audio-Sink
55 // dekodiert das Format).
56 type Edge struct {
57 // Endpoint ist die WebSocket-URL (ws:// oder wss://), leer = Default.
58 Endpoint string
59
60 // Voices ist das config-Mapping voice.edge.<lang> = id; leer = neutrale
61 // Repo-Defaults (de-DE-KatjaNeural / en-US-AriaNeural).
62 Voices map[string]string
63
64 // Dialer ist der WebSocket-Dialer; nil = websocket.DefaultDialer.
65 Dialer *websocket.Dialer
66
67 pinned string // --voice-Pin (VoicePinner)
68 }
69
70 // NewEdge baut den Provider aus der Ketten-Konfiguration: Endpunkt aus
71 // TALK_EDGE_ENDPOINT > [providers.edge].endpoint > Default.
72 func NewEdge(cfg ChainConfig) *Edge {
73 return &Edge{
74 Endpoint: endpointFrom(cfg, "edge", EnvEdgeEndpoint, DefaultEndpointEdge),
75 Voices: cfg.Voices["edge"],
76 }
77 }
78
79 // Name implementiert Provider.
80 func (*Edge) Name() string { return "edge" }
81
82 // PinVoice implementiert VoicePinner (CLI-Flag --voice).
83 func (p *Edge) PinVoice(id string) { p.pinned = id }
84
85 // Available implementiert Provider: Edge braucht nur einen Endpunkt —
86 // bewusst ohne Netz-Call (Erreichbarkeit stellt Synthesize fest, an deren
87 // Fehler fällt die Kette weiter).
88 func (p *Edge) Available(ctx context.Context) bool { return p.Endpoint != "" }
89
90 // Synthesize implementiert Provider. Rückgabeformat: MP3.
91 func (p *Edge) Synthesize(ctx context.Context, text, lang string) ([]byte, error) {
92 if text == "" {
93 return nil, fmt.Errorf("edge: leerer Text")
94 }
95 voice := resolveVoice("edge", lang, p.pinned, p.Voices)
96 if voice == "" {
97 return nil, fmt.Errorf("edge: keine Stimme für Sprache %q konfiguriert", lang)
98 }
99 if _, hasDeadline := ctx.Deadline(); !hasDeadline {
100 var cancel context.CancelFunc
101 ctx, cancel = context.WithTimeout(ctx, edgeIOTimeout)
102 defer cancel()
103 }
104
105 conn, wsURL, err := p.dialWithSkewRetry(ctx)
106 if err != nil {
107 return nil, err
108 }
109 defer conn.Close()
110
111 now := time.Now().UTC()
112 messages := [][]byte{
113 []byte(edgeSpeechConfigMsg(now)),
114 []byte(edgeSynthesisHeaders(now, edgeRequestID()) + edgeSSML(voice, text)),
115 }
116 for _, msg := range messages {
117 if err := conn.SetWriteDeadline(time.Now().Add(edgeIOTimeout)); err != nil {
118 return nil, protocolErr(err)
119 }
120 if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
121 return nil, protocolErr(err)
122 }
123 }
124
125 var audio bytes.Buffer
126 for {
127 if err := conn.SetReadDeadline(time.Now().Add(edgeIOTimeout)); err != nil {
128 return nil, protocolErr(err)
129 }
130 msgType, msg, err := conn.ReadMessage()
131 if err != nil {
132 return nil, protocolErr(err)
133 }
134 switch msgType {
135 case websocket.TextMessage:
136 if strings.Contains(string(msg), "Path:turn.end") {
137 if audio.Len() == 0 {
138 return nil, fmt.Errorf("edge: Server meldete turn.end ohne Audio-Frames — Protokoll geändert? (%s)", redactURL(wsURL))
139 }
140 return audio.Bytes(), nil
141 }
142 case websocket.BinaryMessage:
143 headerText, payload, ok := splitEdgeBinary(msg)
144 if ok && strings.Contains(headerText, "Path:audio") {
145 audio.Write(payload)
146 }
147 }
148 }
149 }
150
151 // dialWithSkewRetry baut die Verbindung; bei 403 (Uhrabweichung) wird der
152 // Server-Zeitstempel übernommen und EINMAL wiederholt.
153 func (p *Edge) dialWithSkewRetry(ctx context.Context) (*websocket.Conn, string, error) {
154 conn, wsURL, resp, err := p.dial(ctx)
155 if err == nil {
156 return conn, wsURL, nil
157 }
158 if resp != nil && resp.StatusCode == http.StatusForbidden {
159 if serverDate := resp.Header.Get("Date"); serverDate != "" {
160 if t, perr := http.ParseTime(serverDate); perr == nil {
161 edgeClockSkew.Store(t.Unix() - time.Now().Unix())
162 conn, wsURL, resp, err = p.dial(ctx)
163 if err == nil {
164 return conn, wsURL, nil
165 }
166 }
167 }
168 }
169 if resp != nil {
170 return nil, wsURL, fmt.Errorf("edge: WebSocket-Verbindung zu %s fehlgeschlagen (HTTP %d; Netz/Timeout oder Protokoll geändert?): %w",
171 redactURL(wsURL), resp.StatusCode, err)
172 }
173 return nil, wsURL, fmt.Errorf("edge: WebSocket-Verbindung zu %s fehlgeschlagen — Netz/Timeout oder das Edge-TTS-Protokoll hat sich geändert: %w",
174 redactURL(wsURL), err)
175 }
176
177 // dial öffnet die Verbindung gemäß Protokoll: Query mit Token,
178 // ConnectionId, Sec-MS-GEC (v2) und Version; Header mit Origin,
179 // Edge-User-Agent und MUID-Cookie.
180 func (p *Edge) dial(ctx context.Context) (*websocket.Conn, string, *http.Response, error) {
181 wsURL, err := p.requestURL(time.Now())
182 if err != nil {
183 return nil, "", nil, err
184 }
185 dialer := p.Dialer
186 if dialer == nil {
187 dialer = websocket.DefaultDialer
188 }
189 header := http.Header{}
190 header.Set("Origin", edgeOrigin)
191 header.Set("User-Agent", edgeUserAgent)
192 header.Set("Pragma", "no-cache")
193 header.Set("Cache-Control", "no-cache")
194 header.Set("Accept-Language", "en-US,en;q=0.9")
195 header.Set("Cookie", "muid="+edgeMUID())
196 conn, resp, err := dialer.DialContext(ctx, wsURL, header)
197 if err != nil {
198 return nil, wsURL, resp, err
199 }
200 return conn, wsURL, nil, nil
201 }
202
203 // requestURL ergänzt die Protokoll-Query-Parameter und validiert das
204 // Schema (ws/wss).
205 func (p *Edge) requestURL(now time.Time) (string, error) {
206 if p.Endpoint == "" {
207 return "", fmt.Errorf("edge: kein Endpunkt konfiguriert")
208 }
209 u, err := url.Parse(p.Endpoint)
210 if err != nil {
211 return "", fmt.Errorf("edge: Endpunkt %q unlesbar: %w", p.Endpoint, err)
212 }
213 if u.Scheme != "ws" && u.Scheme != "wss" {
214 return "", fmt.Errorf("edge: Endpunkt %q muss ws:// oder wss:// sein (ist %q)", p.Endpoint, u.Scheme)
215 }
216 q := u.Query()
217 q.Set("TrustedClientToken", edgeTrustedClientToken)
218 q.Set("ConnectionId", edgeRequestID())
219 q.Set("Sec-MS-GEC", edgeSecMSGEC(now))
220 q.Set("Sec-MS-GEC-Version", edgeSecMSGECVersion)
221 u.RawQuery = q.Encode()
222 return u.String(), nil
223 }
224
225 // edgeSecMSGEC erzeugt den DRM-Token Sec-MS-GEC v2: Windows-Filetime-Ticks
226 // (100 ns, 5-Minuten-Raster, Uhrenkorrektur) + Trusted-Client-Token →
227 // „<CS64>-<SHA256-Variante>" (beides großes Hex, siehe edgedrm.go).
228 func edgeSecMSGEC(now time.Time) string {
229 secs := now.Unix() + edgeClockSkew.Load() + drmWinEpoch
230 secs -= secs % 300 // 5-Minuten-Raster
231 ticks := uint64(secs) * 10_000_000
232 strToHash := fmt.Appendf(nil, "%d%s", ticks, edgeTrustedClientToken)
233 digest := drmSHA256Digest(strToHash)
234 cs0, cs1 := drmCS64(strToHash, digest)
235
236 var cs64Buf [8]byte
237 cs64Buf[0] = byte(cs0)
238 cs64Buf[1] = byte(cs0 >> 8)
239 cs64Buf[2] = byte(cs0 >> 16)
240 cs64Buf[3] = byte(cs0 >> 24)
241 cs64Buf[4] = byte(cs1)
242 cs64Buf[5] = byte(cs1 >> 8)
243 cs64Buf[6] = byte(cs1 >> 16)
244 cs64Buf[7] = byte(cs1 >> 24)
245
246 var shaBuf [32]byte
247 for i, w := range digest {
248 shaBuf[i*4] = byte(w >> 24)
249 shaBuf[i*4+1] = byte(w >> 16)
250 shaBuf[i*4+2] = byte(w >> 8)
251 shaBuf[i*4+3] = byte(w)
252 }
253 return strings.ToUpper(hex.EncodeToString(cs64Buf[:])) + "-" + strings.ToUpper(hex.EncodeToString(shaBuf[:]))
254 }
255
256 // protocolErr überführt Lese-/Schreib-/Verbindungsabbrüche in klare
257 // „Protokoll geändert?"-Fehler — Edge ist ein undokumentierter Endpoint,
258 // Änderungen sollen sofort diagnostizierbar sein.
259 func protocolErr(err error) error {
260 return fmt.Errorf("edge: Kommunikation fehlgeschlagen — Netz/Timeout oder das Edge-TTS-Protokoll hat sich geändert: %w", err)
261 }
262
263 // splitEdgeBinary zerlegt eine binäre Server-Nachricht: Die ersten 2 Bytes
264 // (big-endian) geben die Länge des Header-Blocks an (CRLF-Zeilen wie
265 // „Path:audio"), danach kommt die Audio-Payload.
266 func splitEdgeBinary(msg []byte) (header string, payload []byte, ok bool) {
267 if len(msg) < 2 {
268 return "", nil, false
269 }
270 headerLen := int(binary.BigEndian.Uint16(msg[0:2]))
271 if 2+headerLen > len(msg) {
272 return "", nil, false
273 }
274 return string(msg[2 : 2+headerLen]), msg[2+headerLen:], true
275 }
276
277 // edgeSpeechConfigMsg ist die erste Client-Nachricht: Header-Block +
278 // JSON-Body mit Ausgabeformat und Metadaten-Optionen (Satzgrenzen an,
279 // Wortgrenzen aus — Referenz-Default).
280 func edgeSpeechConfigMsg(now time.Time) string {
281 return fmt.Sprintf(
282 "X-Timestamp:%s\r\nContent-Type:application/json; charset=utf-8\r\nPath:speech.config\r\n\r\n"+
283 `{"context":{"synthesis":{"audio":{"metadataoptions":{"sentenceBoundaryEnabled":"true","wordBoundaryEnabled":"false"},"outputFormat":%q}}}}`,
284 edgeTimestamp(now), edgeOutputFormat)
285 }
286
287 // edgeSynthesisHeaders ist der Header-Block der SSML-Anfrage. Der
288 // nachgestellte Z beim Timestamp ist Referenz-Verhalten („Microsoft Edge
289 // bug"), bewusst mitgeschleppt.
290 func edgeSynthesisHeaders(now time.Time, requestID string) string {
291 return fmt.Sprintf(
292 "X-RequestId:%s\r\nContent-Type:application/ssml+xml\r\nX-Timestamp:%sZ\r\nPath:ssml\r\n\r\n",
293 requestID, edgeTimestamp(now))
294 }
295
296 // edgeSSML baut die Sprach-Anfrage (xml:lang wie die Referenz fest
297 // „en-US"; der Text wird von Steuerzeichen befreit und XML-escapt).
298 func edgeSSML(voice, text string) string {
299 return fmt.Sprintf(
300 "<speak version='1.0' xmlns='http://www.w3.org/2001/10/synthesis' xml:lang='en-US'>"+
301 "<voice name='%s'>"+
302 "<prosody pitch='+0Hz' rate='+0%%' volume='+0%%'>%s</prosody>"+
303 "</voice></speak>",
304 voice, edgeEscape(text))
305 }
306
307 // edgeEscape entfernt nicht unterstützte Steuerzeichen (Referenz:
308 // remove_incompatible_characters) und escapt danach & < >.
309 func edgeEscape(s string) string {
310 clean := make([]byte, 0, len(s))
311 for i := 0; i < len(s); i++ {
312 c := s[i]
313 if c <= 8 || (c >= 11 && c <= 12) || (c >= 14 && c <= 31) {
314 c = ' '
315 }
316 clean = append(clean, c)
317 }
318 esc := make([]byte, 0, len(clean))
319 for _, c := range clean {
320 switch c {
321 case '&':
322 esc = append(esc, "&amp;"...)
323 case '<':
324 esc = append(esc, "&lt;"...)
325 case '>':
326 esc = append(esc, "&gt;"...)
327 default:
328 esc = append(esc, c)
329 }
330 }
331 return string(esc)
332 }
333
334 // edgeTimestamp formatiert den Zeitstempel im edge-tts-Format.
335 func edgeTimestamp(now time.Time) string {
336 return now.Format("Mon Jan 02 2006 15:04:05 GMT+0000 (Coordinated Universal Time)")
337 }
338
339 // edgeRequestID erzeugt eine 32-stellige Hex-ID (UUID v4 ohne Striche).
340 func edgeRequestID() string {
341 var b [16]byte
342 if _, err := rand.Read(b[:]); err != nil {
343 // Fallback: zeitbasiert — die ID muss nur eindeutig pro Anfrage sein.
344 now := time.Now().UnixNano()
345 for i := 0; i < 16; i++ {
346 b[i] = byte(now >> (uint(i%8) * 8))
347 }
348 }
349 b[6] = (b[6] & 0x0f) | 0x40 // UUID-Version
350 b[8] = (b[8] & 0x3f) | 0x80 // Variante
351 return hex.EncodeToString(b[:])
352 }
353
354 // edgeMUID erzeugt die zufällige MUID für das Cookie (32 Hex, groß).
355 func edgeMUID() string {
356 var b [16]byte
357 if _, err := rand.Read(b[:]); err != nil {
358 return edgeRequestID()
359 }
360 return strings.ToUpper(hex.EncodeToString(b[:]))
361 }
362
363 // redactURL maskiert die Query-Parameter (enthalten DRM-Token und IDs) in
364 // Fehlermeldungen — die URL selbst genügt zur Diagnose.
365 func redactURL(raw string) string {
366 u, err := url.Parse(raw)
367 if err != nil {
368 return raw
369 }
370 u.RawQuery = "…"
371 return u.String()
372 }
373
internal/provider/edge_test.go --- Go
1 package provider
2
3 import (
4 "context"
5 "encoding/hex"
6 "net/http"
7 "net/http/httptest"
8 "os"
9 "strings"
10 "sync"
11 "testing"
12 "time"
13
14 "github.com/gorilla/websocket"
15 )
16
17 // TestEdgeAvailable: Edge braucht nur einen Endpunkt, keinen Key — der
18 // Konstruktor bäkt den Default-Endpunkt ein, daher ist ein über NewEdge
19 // gebauter Edge immer verfügbar; nur der Nullwert (direkt gebaut, ohne
20 // Endpunkt) ist es nicht.
21 func TestEdgeAvailable(t *testing.T) {
22 if (&Edge{}).Available(context.Background()) {
23 t.Error("Available ohne Endpunkt (Nullwert) = true, want false")
24 }
25 p := NewEdge(ChainConfig{Getenv: envMap(map[string]string{
26 EnvEdgeEndpoint: "wss://edge.example.com",
27 })})
28 if !p.Available(context.Background()) {
29 t.Error("Available mit Endpunkt = false, want true")
30 }
31 if !NewEdge(ChainConfig{Getenv: envMap(nil)}).Available(context.Background()) {
32 t.Error("Available mit Default-Endpunkt = false, want true (edge ist der Key-freie Fallback)")
33 }
34 }
35
36 // TestEdgeVerbindungFehlschlaegt (Fehlerpfad-Mock): ein garantiert toter
37 // Loopback-Port liefert einen klaren Fehler — sofort, ohne Netz.
38 func TestEdgeVerbindungFehlschlaegt(t *testing.T) {
39 p := NewEdge(ChainConfig{Getenv: envMap(map[string]string{
40 EnvEdgeEndpoint: "ws://127.0.0.1:1/",
41 })})
42 _, err := p.Synthesize(context.Background(), "Hallo", "de")
43 if err == nil {
44 t.Fatal("Synthesize gegen toten Port: kein Fehler")
45 }
46 if !strings.Contains(err.Error(), "edge") || !strings.Contains(err.Error(), "WebSocket") {
47 t.Errorf("Fehler = %v, will klaren edge/WebSocket-Fehler", err)
48 }
49 }
50
51 // TestEdgeUngueltigesSchema: ein Nicht-w(s)-Endpunkt ist ein klarer Fehler.
52 func TestEdgeUngueltigesSchema(t *testing.T) {
53 p := NewEdge(ChainConfig{Getenv: envMap(map[string]string{
54 EnvEdgeEndpoint: "http://127.0.0.1:1234/",
55 })})
56 _, err := p.Synthesize(context.Background(), "Hallo", "de")
57 if err == nil || !strings.Contains(err.Error(), "ws://") {
58 t.Errorf("Fehler = %v, will Schema-Hinweis ws://", err)
59 }
60 }
61
62 // TestEdgeSSML: die SSML-Anfrage enthält Stimme und den von Steuerzeichen
63 // befreiten, XML-escapten Text; xml:lang ist wie in der Referenz fest
64 // „en-US".
65 func TestEdgeSSML(t *testing.T) {
66 got := edgeSSML("de-DE-KatjaNeural", "AT&T <Kunde> „fertig“")
67 for _, want := range []string{
68 "voice name='de-DE-KatjaNeural'",
69 "xml:lang='en-US'",
70 "AT&amp;T &lt;Kunde&gt;",
71 } {
72 if !strings.Contains(got, want) {
73 t.Errorf("SSML = %q, will %q", got, want)
74 }
75 }
76 if strings.Contains(got, "AT&T") {
77 t.Errorf("SSML enthält unescaptes &: %q", got)
78 }
79 }
80
81 // TestEdgeEscape: Steuerzeichen (u. a. Vertical Tab aus OCR-Texten) werden
82 // durch Leerzeichen ersetzt, & < > escapt.
83 func TestEdgeEscape(t *testing.T) {
84 in := "a\x0bb&c<d>\x08e"
85 if want := "a b&amp;c&lt;d&gt; e"; edgeEscape(in) != want {
86 t.Errorf("edgeEscape(%q) = %q, want %q", in, edgeEscape(in), want)
87 }
88 }
89
90 // TestEdgeSpeechConfigMsg: Output-Format (mono-mp3) und Pfad stimmen.
91 func TestEdgeSpeechConfigMsg(t *testing.T) {
92 msg := edgeSpeechConfigMsg(time.Unix(0, 0).UTC())
93 if !strings.Contains(msg, "Path:speech.config") {
94 t.Errorf("speech.config-Nachricht ohne Path: %q", msg)
95 }
96 if !strings.Contains(msg, edgeOutputFormat) {
97 t.Errorf("speech.config-Nachricht ohne Ausgabeformat %q: %q", edgeOutputFormat, msg)
98 }
99 }
100
101 // TestEdgeRequestURL: Query-Parameter (Token, Sec-MS-GEC, Version) werden
102 // ergänzt; der Token landet nicht in der URL-Debug-Ausgabe.
103 func TestEdgeRequestURL(t *testing.T) {
104 p := &Edge{Endpoint: "wss://edge.example.com/path"}
105 raw, err := p.requestURL(time.Unix(1700000000, 0).UTC())
106 if err != nil {
107 t.Fatalf("requestURL: %v", err)
108 }
109 for _, want := range []string{
110 "TrustedClientToken=" + edgeTrustedClientToken,
111 "Sec-MS-GEC=",
112 "Sec-MS-GEC-Version=" + edgeSecMSGECVersion,
113 } {
114 if !strings.Contains(raw, want) {
115 t.Errorf("requestURL = %q, will %q", raw, want)
116 }
117 }
118 if got := redactURL(raw); strings.Contains(got, edgeTrustedClientToken) {
119 t.Errorf("redactURL leakt den Token: %q", got)
120 }
121 }
122
123 // TestEdgeSecMSGEC: Format „<16 Hex>-<64 Hex>" (groß), 5-Minuten-Raster
124 // (innerhalb des Fensters stabil, über die Grenze anders) und exakte
125 // Referenzvektoren der edge-tts-DRM-Implementierung (v2).
126 func TestEdgeSecMSGEC(t *testing.T) {
127 // Referenzvektoren: (Unix-Sekunden, erwarteter Token) — erzeugt mit der
128 // Python-Referenz (edge-tts src/edge_tts/drm.py, Stand 2026-10).
129 vectors := []struct {
130 ts int64
131 want string
132 }{
133 {1700000400, "73C7C85FE76CF347-452672184D494A9F80E179C2FBFEE2E98480C4D4AAFC1F4A577E43B023BEEEC9"},
134 {1700000599, "73C7C85FE76CF347-452672184D494A9F80E179C2FBFEE2E98480C4D4AAFC1F4A577E43B023BEEEC9"},
135 {1765800000, "B9B5447115B4FD1A-6BB36279F5B3D1B53F84F786AF05C81A86909ABE30E695496BBE88FFAE2E3E83"},
136 {0, "52BC7A17B50DFEC9-28C4394775C8751CD2F6859C030C06DB1489ACAB3B3A7651831FC26895177ED6"},
137 }
138 for _, v := range vectors {
139 if got := edgeSecMSGEC(time.Unix(v.ts, 0).UTC()); got != v.want {
140 t.Errorf("edgeSecMSGEC(%d) =\n %s\nwant\n %s", v.ts, got, v.want)
141 }
142 }
143
144 // Fenster-Stabilität (Basis am Fensterbeginn, mod 300 == 0).
145 base := time.Unix(1700000400, 0).UTC()
146 token := edgeSecMSGEC(base)
147 if edgeSecMSGEC(base.Add(4*time.Minute)) != token {
148 t.Error("Sec-MS-GEC weicht innerhalb des 5-Minuten-Fensters ab")
149 }
150 if edgeSecMSGEC(base.Add(6*time.Minute)) == token {
151 t.Error("Sec-MS-GEC ändert sich über die Fenster-Grenze nicht")
152 }
153 if len(token) != 16+1+64 || !strings.Contains(token, "-") {
154 t.Errorf("Token-Format = %q, want <16 Hex>-<64 Hex>", token)
155 }
156 }
157
158 // TestEdgeRequestID: 32 Hex-Zeichen, eindeutig.
159 func TestEdgeRequestID(t *testing.T) {
160 a, b := edgeRequestID(), edgeRequestID()
161 if len(a) != 32 {
162 t.Errorf("RequestID-Länge = %d, want 32", len(a))
163 }
164 if _, err := hex.DecodeString(a); err != nil {
165 t.Errorf("RequestID ist kein Hex: %v", err)
166 }
167 if a == b {
168 t.Error("RequestID nicht eindeutig")
169 }
170 }
171
172 // TestEdgeSplitBinary: Binär-Rahmen: 2 Bytes big-endian Header-Länge, dann
173 // Header (Path:audio), dann Payload.
174 func TestEdgeSplitBinary(t *testing.T) {
175 msg := []byte{0, 12, 'P', 'a', 't', 'h', ':', 'a', 'u', 'd', 'i', 'o', '\r', '\n', 1, 2, 3}
176 header, payload, ok := splitEdgeBinary(msg)
177 if !ok || header != "Path:audio\r\n" || string(payload) != "\x01\x02\x03" {
178 t.Errorf("splitEdgeBinary = %q, %x, %v", header, payload, ok)
179 }
180 if _, _, ok := splitEdgeBinary([]byte{0, 99, 'x'}); ok {
181 t.Error("übergroßer Header-Längenangabe soll abgelehnt werden")
182 }
183 }
184
185 // fakeEdgeServer ist ein Loopback-Fake des Edge-WSS-Protokolls (kein
186 // echter Netz-Call — alles bleibt auf 127.0.0.1). Er prüft die beiden
187 // Client-Nachrichten und antwortet nach Protokoll: Audio-Binär-Rahmen mit
188 // Path:audio, danach turn.end. audio ist die zu liefernde Payload;
189 // closing schließt die Verbindung vor turn.end (Fehlerpfad).
190 func fakeEdgeServer(t *testing.T, audio []byte, closing bool) (*httptest.Server, *fakeEdgeSeen) {
191 t.Helper()
192 seen := &fakeEdgeSeen{}
193 upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
194 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
195 if got := r.URL.Query().Get("TrustedClientToken"); got != edgeTrustedClientToken {
196 t.Errorf("TrustedClientToken = %q", got)
197 }
198 if r.Header.Get("Origin") != edgeOrigin {
199 t.Errorf("Origin = %q", r.Header.Get("Origin"))
200 }
201 conn, err := upgrader.Upgrade(w, r, nil)
202 if err != nil {
203 t.Errorf("Upgrade: %v", err)
204 return
205 }
206 defer conn.Close()
207 if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
208 return
209 }
210 if _, cfg, err := conn.ReadMessage(); err != nil {
211 t.Errorf("speech.config lesen: %v", err)
212 } else {
213 seen.mu.Lock()
214 seen.config = string(cfg)
215 seen.mu.Unlock()
216 }
217 if _, req, err := conn.ReadMessage(); err != nil {
218 t.Errorf("ssml lesen: %v", err)
219 } else {
220 seen.mu.Lock()
221 seen.ssml = string(req)
222 seen.mu.Unlock()
223 }
224 if closing {
225 return // Verbindung dichtmachen, ohne zu antworten
226 }
227 header := "X-RequestId:dummy\r\nPath:audio\r\n"
228 frame := make([]byte, 0, 2+len(header)+len(audio))
229 frame = append(frame, byte(len(header)>>8), byte(len(header)))
230 frame = append(frame, header...)
231 frame = append(frame, audio...)
232 if err := conn.WriteMessage(websocket.BinaryMessage, frame); err != nil {
233 return
234 }
235 if err := conn.WriteMessage(websocket.TextMessage, []byte("X-RequestId:dummy\r\nPath:turn.end\r\n\r\n")); err != nil {
236 return
237 }
238 }))
239 t.Cleanup(ts.Close)
240 return ts, seen
241 }
242
243 type fakeEdgeSeen struct {
244 mu sync.Mutex
245 config string
246 ssml string
247 }
248
249 func (s *fakeEdgeSeen) snapshot() (configMsg, ssml string) {
250 s.mu.Lock()
251 defer s.mu.Unlock()
252 return s.config, s.ssml
253 }
254
255 // TestEdgeLoopbackFakeServer: kompletter Protokoll-Durchlauf gegen den
256 // Loopback-Fake (fake-bar getestet: Handshake-Parameter, beide
257 // Client-Nachrichten, Binär-Rahmen-Parsing, turn.end-Ende) — siehe
258 // Task-03-Report für die Abgrenzung zum echten Endpoint.
259 func TestEdgeLoopbackFakeServer(t *testing.T) {
260 wantAudio := []byte("\xff\xfb\x90\x00" + "edge-audio")
261 ts, seen := fakeEdgeServer(t, wantAudio, false)
262
263 p := NewEdge(ChainConfig{Getenv: envMap(map[string]string{
264 EnvEdgeEndpoint: "ws://" + ts.Listener.Addr().String(),
265 })})
266 data, err := p.Synthesize(context.Background(), "Hallo & fertig", "de")
267 if err != nil {
268 t.Fatalf("Synthesize gegen Loopback-Fake: %v", err)
269 }
270 if string(data) != string(wantAudio) {
271 t.Errorf("Synthesize = %x, want %x", data, wantAudio)
272 }
273 configMsg, ssml := seen.snapshot()
274 if !strings.Contains(configMsg, "Path:speech.config") {
275 t.Errorf("speech.config = %q", configMsg)
276 }
277 if !strings.Contains(ssml, "Path:ssml") {
278 t.Errorf("ssml-Anfrage ohne Path:ssml: %q", ssml)
279 }
280 if !strings.Contains(ssml, "voice name='de-DE-KatjaNeural'") {
281 t.Errorf("ssml ohne Default-Stimme de: %q", ssml)
282 }
283 if !strings.Contains(ssml, "Hallo &amp; fertig") {
284 t.Errorf("ssml ohne escapten Text: %q", ssml)
285 }
286 }
287
288 // TestEdgeLoopbackVerbindungsAbbruch: Server schließt nach den Anfragen
289 // ohne Audio → klarer „Protokoll geändert?"-Fehler.
290 func TestEdgeLoopbackVerbindungsAbbruch(t *testing.T) {
291 ts, _ := fakeEdgeServer(t, nil, true)
292 p := NewEdge(ChainConfig{Getenv: envMap(map[string]string{
293 EnvEdgeEndpoint: "ws://" + ts.Listener.Addr().String(),
294 })})
295 _, err := p.Synthesize(context.Background(), "Hallo", "de")
296 if err == nil || !strings.Contains(err.Error(), "Protokoll") {
297 t.Errorf("Fehler = %v, will Protokoll-Änderungs-Hinweis", err)
298 }
299 }
300
301 // TestEdgeIntegrationEcht ist die als testing.Short überspringbare
302 // Integrations-Markierung gegen den ECHTEN Microsoft-Endpunkt (kostenlos,
303 // aber echtes Netz — Standard-Testläufe und -short überspringen; einmalige
304 // manuelle Abnahme mit TALK_TEST_REAL_EDGE=1).
305 func TestEdgeIntegrationEcht(t *testing.T) {
306 if testing.Short() {
307 t.Skip("Übersprungen (-short)")
308 }
309 if os.Getenv("TALK_TEST_REAL_EDGE") != "1" {
310 t.Skip("Übersprungen (echtes Netz) — mit TALK_TEST_REAL_EDGE=1 ausführen")
311 }
312 p := NewEdge(ChainConfig{})
313 data, err := p.Synthesize(context.Background(), "Kurzer Sprachtest über Edge.", "de")
314 if err != nil {
315 t.Fatalf("Synthesize gegen echten Endpunkt: %v", err)
316 }
317 if len(data) == 0 {
318 t.Fatal("Synthesize gegen echten Endpunkt: 0 Bytes")
319 }
320 t.Logf("Edge-Integration: %d Bytes Audio", len(data))
321 }
322
internal/provider/edgedrm.go --- Go
1 package provider
2
3 import (
4 "encoding/binary"
5 )
6
7 // Edge-DRM: Sec-MS-GEC v2. Microsoft verlangt seit der Protokoll-Änderung
8 // (edge-tts-Referenz, Stand 2026) einen Token der Form
9 // „<16 Hex CS64>-<64 Hex SHA256-Variante>" über den 5-Minuten-gerundeten
10 // Windows-Filetime-Ticks + Trusted-Client-Token. Beide Digests sind
11 // absichtlich verschleierte Varianten bekannter Algorithmen — die
12 // Konstanten und der Kontrollfluss unten sind eine wörtliche Portierung
13 // der Referenz (edge-tts src/edge_tts/drm.py, MIT) und gegen deren
14 // Ausgaben tabellengeprüft (TestEdgeDRMReferenzvektoren). Ändert Microsoft
15 // das Schema erneut, liefert der Edge-Provider klare Fehler („Protokoll
16 // geändert?"), statt still zu versagen.
17
18 const (
19 drmWinEpoch = 11644473600 // Sekunden zwischen 1601-01-01 und 1970-01-01
20 )
21
22 // drmIV ist der Standard-SHA-256-Initialvektor.
23 var drmIV = [8]uint32{
24 0x6A09E667, 0xBB67AE85, 0x3C6EF372, 0xA54FF53A,
25 0x510E527F, 0x9B05688C, 0x1F83D9AB, 0x5BE0CD19,
26 }
27
28 // drmMidState ist der Arbeitsregister-Stand der Referenz für den
29 // Mid-Block-Einstieg (Runde 14 des ersten Blocks).
30 var drmMidState = [8]uint32{
31 0xFD9FBEA2, 0x91243CE7, 0xBE499094, 0xCD2E1E0A,
32 0x6C006F49, 0x04BD35AB, 0xFEB0A7E4, 0x03EFFBD2,
33 }
34
35 // drmSchedule sind vorberechnete Schedule-Anteile des ersten Blocks.
36 var drmSchedule = [14]uint32{
37 0xEB24BF9E, 0x1558691F, 0xA0C09EE2, 0x0CFA32BA,
38 0xA622EE8C, 0xD1DD9491, 0xC0DD25A0, 0xF9E70AB6,
39 0xF0C31573, 0x7058CE4E, 0x7188E606, 0x00E5AB33,
40 0xD258B060, 0x72766564,
41 }
42
43 // drmAdjustment ersetzt die Rundungskonstante K[14] im Mid-Block-Pfad.
44 const (
45 drmAdjustment uint32 = 0xC9DC06A7
46 drmSigma0Adj uint32 = 0x059C0B80
47 drmSigma1Adj uint32 = 0x000B92C0
48 drmCS64Factor uint32 = 0x82893933
49 drmCS64Adj0 uint32 = 0xF2F90B1A
50 drmCS64Adj1 uint32 = 0x0B1A0000
51 )
52
53 // drmK sind die Standard-SHA-256-Rundungskonstanten.
54 var drmK = [64]uint32{
55 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
56 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
57 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
58 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
59 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
60 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
61 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
62 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
63 }
64
65 // drmCS64Coefficients: (Term-, Offset-Koeffizient) je Zeile.
66 var drmCS64Coefficients = [14][2]uint32{
67 {0xE82E91B1, 0xB32700D0}, {0x44DE24C6, 0x88DFF51D},
68 {0x3BEB65BC, 0x29514038}, {0xC89C5933, 0xB328CBDF},
69 {0x8D1EC211, 0xD405B4D5}, {0x314EF5D5, 0x26E55F3C},
70 {0xE92491B1, 0x607801C6}, {0xF0A5B9D5, 0x82B2C1FB},
71 {0x06064CF3, 0xFA51A3DA}, {0xDA79FA60, 0x207CC464},
72 {0xBED20560, 0x3C3C74F3}, {0x89F9C9F8, 0xD2D14B61},
73 {0x35FA38C6, 0xBB04A2C1}, {0xCB49E3C6, 0xC5626167},
74 }
75
76 // drmSigma0 ist die (verschleiert notierte) SHA-256-σ0-Mischfunktion.
77 func drmSigma0(v uint32) uint32 {
78 return v>>7 ^ v<<25 ^ v>>18 ^ v<<14 ^ v>>3
79 }
80
81 // drmSigma1 ist die SHA-256-σ1-Mischfunktion.
82 func drmSigma1(v uint32) uint32 {
83 return v>>17 ^ v<<15 ^ v>>19 ^ v<<13 ^ v>>10
84 }
85
86 // drmRol16 rotiert 32 Bit um 16.
87 func drmRol16(v uint32) uint32 { return v<<16 | v>>16 }
88
89 // drmRotR rotiert 32 Bit rechts um n.
90 func drmRotR(v uint32, n uint) uint32 { return v>>n | v<<(32-n) }
91
92 // drmExpand erweitert die Message-Schedule ab from auf 64 Wörter.
93 func drmExpand(words []uint32, from int) []uint32 {
94 for i := from; i < 64; i++ {
95 x, y := words[i-2], words[i-15]
96 words = append(words, drmSigma1(x)+words[i-7]+drmSigma0(y)+words[i-16])
97 }
98 return words
99 }
100
101 // drmRounds führt die SHA-256-Runden aus — wörtlich nach Referenz,
102 // inklusive des Mid-Block-Einstiegs (firstRound 14 mit ersetzter
103 // Rundungskonstante) und der 8er-Rollen-Rotation der Register.
104 func drmRounds(state [8]uint32, words []uint32, firstRound int) [8]uint32 {
105 a, b, c, d, e, f, g, h := state[0], state[1], state[2], state[3], state[4], state[5], state[6], state[7]
106 midBlock := firstRound == 14
107 k := func(i int) uint32 {
108 if midBlock && i == 14 {
109 return drmAdjustment
110 }
111 return drmK[i]
112 }
113 aligned := ((firstRound + 7) / 8) * 8
114 if aligned > 64 {
115 aligned = 64
116 }
117 for i := firstRound; i < aligned; i++ {
118 t1 := h + (drmRotR(e, 6) ^ drmRotR(e, 11) ^ drmRotR(e, 25)) + (g ^ (e & (f ^ g))) + k(i) + words[i]
119 t2 := (drmRotR(a, 2) ^ drmRotR(a, 13) ^ drmRotR(a, 22)) + ((a & b) | (c & (a | b)))
120 a, b, c, d, e, f, g, h = t1+t2, a, b, c, d+t1, e, f, g
121 }
122 for i := aligned; i < 64; i += 8 {
123 // Runde i
124 t1 := h + (drmRotR(e, 6) ^ drmRotR(e, 11) ^ drmRotR(e, 25)) + (g ^ (e & (f ^ g))) + k(i) + words[i]
125 d += t1
126 h = t1 + (drmRotR(a, 2) ^ drmRotR(a, 13) ^ drmRotR(a, 22)) + ((a & b) | (c & (a | b)))
127 // Runde i+1 (Rollen rotiert)
128 t1 = g + (drmRotR(d, 6) ^ drmRotR(d, 11) ^ drmRotR(d, 25)) + (f ^ (d & (e ^ f))) + k(i+1) + words[i+1]
129 c += t1
130 g = t1 + (drmRotR(h, 2) ^ drmRotR(h, 13) ^ drmRotR(h, 22)) + ((h & a) | (b & (h | a)))
131 // Runde i+2
132 t1 = f + (drmRotR(c, 6) ^ drmRotR(c, 11) ^ drmRotR(c, 25)) + (e ^ (c & (d ^ e))) + k(i+2) + words[i+2]
133 b += t1
134 f = t1 + (drmRotR(g, 2) ^ drmRotR(g, 13) ^ drmRotR(g, 22)) + ((g & h) | (a & (g | h)))
135 // Runde i+3
136 t1 = e + (drmRotR(b, 6) ^ drmRotR(b, 11) ^ drmRotR(b, 25)) + (d ^ (b & (c ^ d))) + k(i+3) + words[i+3]
137 a += t1
138 e = t1 + (drmRotR(f, 2) ^ drmRotR(f, 13) ^ drmRotR(f, 22)) + ((f & g) | (h & (f | g)))
139 // Runde i+4
140 t1 = d + (drmRotR(a, 6) ^ drmRotR(a, 11) ^ drmRotR(a, 25)) + (c ^ (a & (b ^ c))) + k(i+4) + words[i+4]
141 h += t1
142 d = t1 + (drmRotR(e, 2) ^ drmRotR(e, 13) ^ drmRotR(e, 22)) + ((e & f) | (g & (e | f)))
143 // Runde i+5
144 t1 = c + (drmRotR(h, 6) ^ drmRotR(h, 11) ^ drmRotR(h, 25)) + (b ^ (h & (a ^ b))) + k(i+5) + words[i+5]
145 g += t1
146 c = t1 + (drmRotR(d, 2) ^ drmRotR(d, 13) ^ drmRotR(d, 22)) + ((d & e) | (f & (d | e)))
147 // Runde i+6
148 t1 = b + (drmRotR(g, 6) ^ drmRotR(g, 11) ^ drmRotR(g, 25)) + (a ^ (g & (h ^ a))) + k(i+6) + words[i+6]
149 f += t1
150 b = t1 + (drmRotR(c, 2) ^ drmRotR(c, 13) ^ drmRotR(c, 22)) + ((c & d) | (e & (c | d)))
151 // Runde i+7
152 t1 = a + (drmRotR(f, 6) ^ drmRotR(f, 11) ^ drmRotR(f, 25)) + (h ^ (f & (g ^ h))) + k(i+7) + words[i+7]
153 e += t1
154 a = t1 + (drmRotR(b, 2) ^ drmRotR(b, 13) ^ drmRotR(b, 22)) + ((b & c) | (d & (b | c)))
155 }
156 return [8]uint32{a, b, c, d, e, f, g, h}
157 }
158
159 // drmSHA256Init berechnet den Zustand nach dem (verschleiert notierten)
160 // ersten Block: 14 Nullwörter + die ersten 7 Bytes von data als
161 // 24-Bit-/32-Bit-Big-Endian-Wörter, Schedule mit Korrekturen, Runden ab 14.
162 func drmSHA256Init(data []byte) [8]uint32 {
163 words := make([]uint32, 30, 64)
164 words[14] = uint32(data[0])<<16 | uint32(data[1])<<8 | uint32(data[2])
165 words[15] = binary.BigEndian.Uint32(data[3:7])
166 for i := 16; i < 30; i++ {
167 v := drmSchedule[i-16]
168 // σ1-Anteil von W[i-2] (Index 16 liest W[14], dazu die Korrektur).
169 s1 := drmSigma1(words[i-2])
170 if i == 16 {
171 s1 ^= drmSigma1Adj
172 }
173 v += s1
174 if i-7 >= 14 {
175 v += words[i-7]
176 }
177 if i-15 >= 14 { // nur i == 29, liest W[14], dazu die Korrektur
178 s0 := drmSigma0(words[i-15])
179 if i == 29 {
180 s0 ^= drmSigma0Adj
181 }
182 v += s0
183 }
184 words[i] = v
185 }
186 words = append(words, drmSigma1(words[28])+words[23]+drmSigma0(words[15])+words[14]+drmAdjustment-drmK[14])
187 words = drmExpand(words, 31)
188 working := drmRounds(drmMidState, words, 14)
189 var out [8]uint32
190 for i := range out {
191 out[i] = drmIV[i] + working[i]
192 }
193 return out
194 }
195
196 // drmSHA256Digest ist die verschleierte SHA-256-Variante der Referenz:
197 // Block 1 steckt in drmSHA256Init (erste 7 Bytes), danach data[7:] mit
198 // Referenz-Längenfeld (57+len(data)) und Standard-Runden.
199 func drmSHA256Digest(data []byte) [8]uint32 {
200 length := 57 + len(data)
201 padded := append([]byte(nil), data[7:]...)
202 padded = append(padded, 0x80)
203 zeros := (55 - length) % 64
204 if zeros < 0 {
205 zeros += 64
206 }
207 padded = append(padded, make([]byte, zeros)...)
208 var lenBuf [8]byte
209 binary.BigEndian.PutUint64(lenBuf[:], uint64(length)*8)
210 padded = append(padded, lenBuf[:]...)
211
212 state := drmSHA256Init(data[:7])
213 for off := 0; off < len(padded); off += 64 {
214 words := make([]uint32, 16, 64)
215 for i := range words {
216 words[i] = binary.BigEndian.Uint32(padded[off+i*4:])
217 }
218 words = drmExpand(words, 16)
219 working := drmRounds(state, words, 0)
220 for i := range state {
221 state[i] += working[i]
222 }
223 }
224 return state
225 }
226
227 // drmWordsLE4Even zerlegt data in little-endian u32-Wörter (auf 4 Bytes
228 // null-aufgefüllt, Wortzahl gerade).
229 func drmWordsLE4Even(data []byte) []uint32 {
230 padded := make([]byte, len(data))
231 copy(padded, data)
232 if r := len(padded) % 4; r != 0 {
233 padded = append(padded, make([]byte, 4-r)...)
234 }
235 words := make([]uint32, len(padded)/4)
236 for i := range words {
237 words[i] = binary.LittleEndian.Uint32(padded[i*4:])
238 }
239 if len(words)%2 == 1 {
240 words = append(words, 0)
241 }
242 return words
243 }
244
245 // drmCS64 berechnet die beiden CS64-Prüfsummen-Wörter (wörtliche
246 // Portierung; Schlüssel aus den ersten 8 Digest-Bytes little-endian).
247 func drmCS64(data []byte, digest [8]uint32) (a, b uint32) {
248 var digestBytes [32]byte
249 for i, w := range digest {
250 binary.BigEndian.PutUint32(digestBytes[i*4:], w)
251 }
252 keyA := binary.LittleEndian.Uint32(digestBytes[0:4]) | 1
253 keyB := binary.LittleEndian.Uint32(digestBytes[4:8]) | 1
254 factors := [2]uint32{keyA * drmCS64Factor, keyB * drmCS64Factor}
255
256 terms := make([]uint32, 0, 14+8)
257 offsets := make([]uint32, 0, 14+8)
258 for i := range drmCS64Coefficients {
259 terms = append(terms, drmCS64Coefficients[i][0]*factors[i%2])
260 offsets = append(offsets, drmCS64Coefficients[i][1])
261 }
262 for i, word := range drmWordsLE4Even(append([]byte{0}, data...)) {
263 var term, offset uint32
264 if i%2 == 0 {
265 term = word * keyA
266 offset = drmRol16(word) * 0xEF0569FB
267 } else {
268 term = word * keyB
269 offset = drmRol16(word) * 0xC31713DB
270 }
271 if i == 0 {
272 term += drmCS64Adj0 * factors[0]
273 offset += drmCS64Adj1
274 }
275 terms = append(terms, term)
276 offsets = append(offsets, offset)
277 }
278
279 var swapAcc, swapLast, st, acc uint32
280 for i := 0; i < len(terms); i += 2 {
281 x, y := terms[i], terms[i+1]
282 t := drmRol16(offsets[i] + x)
283 q := t * 0x76F50F34
284 swapAcc += drmRol16(q)*0xC3EFEA97 + q
285 t = drmRol16(offsets[i+1] + y)
286 q = t * 0x1881CE3C
287 swapLast = drmRol16(q)*0x35BD1EC9 + q
288 swapAcc += swapLast
289
290 st = st*keyA + x
291 st = drmRol16(st) * 0xCF98B111
292 st = drmRol16(st) * 0x87085B9F
293 st = drmRol16(st) * 0x12CEB96D
294 st = drmRol16(st) * 0x257E1D83
295 acc += st
296 t = st*keyB + y
297 t = drmRol16(t) * 0xA27416F5
298 t = drmRol16(t) * 0xD38396FF
299 st = drmRol16(drmRol16(t)*0x7C932B89) * 0xBFA49F69
300 acc += st
301 }
302 return swapLast ^ st, swapAcc ^ acc
303 }
304
internal/provider/elevenlabs.go --- Go
1 package provider
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "net/http"
8 "strings"
9 )
10
11 // ElevenLabs-Konstanten (öffentlicher Vertrag: Env-Namen sind Teil der
12 // Fleet-Doku, Endpunkt-Default Teil der Konfigurierbarkeit).
13 const (
14 // DefaultEndpointElevenLabs ist der produktive API-Endpunkt.
15 DefaultEndpointElevenLabs = "https://api.elevenlabs.io"
16
17 // EnvElevenLabsKey liefert den API-Key (Spec: Keys ausschließlich Env,
18 // nie config.toml).
19 EnvElevenLabsKey = "TALK_ELEVENLABS_KEY"
20
21 // EnvElevenLabsEndpoint überschreibt den Endpunkt (Tests/Firmenserver;
22 // schlägt die config.toml, siehe voices.go/chain.go-Doku).
23 EnvElevenLabsEndpoint = "TALK_ELEVENLABS_ENDPOINT"
24
25 elevenLabsModel = "eleven_multilingual_v2" // multilingual v2, Task-Brief
26 )
27
28 // ElevenLabs ist die Premium-Stufe der Kette. Synthesize führt POST
29 // <endpoint>/v1/text-to-speech/<voiceID> aus — Header `xi-api-key`, Body
30 // {text, model_id: "eleven_multilingual_v2", voice_settings:
31 // {stability: 0.5, similarity_boost: 0.75}} — und liefert die Antwort-Bytes
32 // unverändert zurück. Rückgabeformat: MP3 (dokumentierter Vertrag des
33 // audio-Pakets).
34 type ElevenLabs struct {
35 // Endpoint ist die Basis-URL (ohne Pfad); leer wird beim Bau durch den
36 // Default ersetzt.
37 Endpoint string
38
39 // APIKey ist der ElevenLabs-Key (aus TALK_ELEVENLABS_KEY).
40 APIKey string
41
42 // Voices ist das config-Mapping voice.elevenlabs.<lang> = id; leer =
43 // neutrale Repo-Defaults (siehe voices.go).
44 Voices map[string]string
45
46 // Client ist der HTTP-Client; nil = geteilter Client mit Timeout
47 // (cloudTimeout).
48 Client *http.Client
49
50 pinned string // --voice-Pin (VoicePinner)
51 }
52
53 // NewElevenLabs baut den Provider aus der Ketten-Konfiguration: Endpunkt
54 // aus TALK_ELEVENLABS_ENDPOINT > [providers.elevenlabs].endpoint > Default;
55 // Key ausschließlich aus TALK_ELEVENLABS_KEY.
56 func NewElevenLabs(cfg ChainConfig) *ElevenLabs {
57 return &ElevenLabs{
58 Endpoint: endpointFrom(cfg, "elevenlabs", EnvElevenLabsEndpoint, DefaultEndpointElevenLabs),
59 APIKey: cfg.Getenv(EnvElevenLabsKey),
60 Voices: cfg.Voices["elevenlabs"],
61 }
62 }
63
64 // Name implementiert Provider.
65 func (*ElevenLabs) Name() string { return "elevenlabs" }
66
67 // PinVoice implementiert VoicePinner (CLI-Flag --voice).
68 func (p *ElevenLabs) PinVoice(id string) { p.pinned = id }
69
70 // Available implementiert Provider: Key und Endpunkt müssen gesetzt sein
71 // (bewusst ohne Netz-Call — Available muss billig bleiben; ob der Key
72 // gültig ist, stellt Synthesize fest und die Kette fällt weiter).
73 func (p *ElevenLabs) Available(ctx context.Context) bool {
74 return p.APIKey != "" && p.Endpoint != ""
75 }
76
77 // elevenLabsRequest ist der Antwort-Body (Task-Brief, wörtliche Werte).
78 type elevenLabsRequest struct {
79 Text string `json:"text"`
80 ModelID string `json:"model_id"`
81 VoiceSettings elevenLabsVoiceSettings `json:"voice_settings"`
82 }
83
84 type elevenLabsVoiceSettings struct {
85 Stability float64 `json:"stability"`
86 SimilarityBoost float64 `json:"similarity_boost"`
87 }
88
89 // Synthesize implementiert Provider. Rückgabeformat: MP3.
90 func (p *ElevenLabs) Synthesize(ctx context.Context, text, lang string) ([]byte, error) {
91 if text == "" {
92 return nil, fmt.Errorf("elevenlabs: leerer Text")
93 }
94 voice := resolveVoice("elevenlabs", lang, p.pinned, p.Voices)
95 if voice == "" {
96 return nil, fmt.Errorf("elevenlabs: keine Stimme für Sprache %q konfiguriert", lang)
97 }
98 body, err := json.Marshal(elevenLabsRequest{
99 Text: text,
100 ModelID: elevenLabsModel,
101 VoiceSettings: elevenLabsVoiceSettings{Stability: 0.5, SimilarityBoost: 0.75},
102 })
103 if err != nil {
104 return nil, fmt.Errorf("elevenlabs: Anfrage-Body bauen: %w", err)
105 }
106 url := strings.TrimRight(p.Endpoint, "/") + "/v1/text-to-speech/" + voice
107 return postForBytes(ctx, p.Client, "elevenlabs", url,
108 map[string]string{
109 "xi-api-key": p.APIKey,
110 "Accept": "audio/mpeg",
111 }, body)
112 }
113
internal/provider/elevenlabs_test.go --- Go
1 package provider
2
3 import (
4 "context"
5 "encoding/json"
6 "io"
7 "net/http"
8 "net/http/httptest"
9 "strings"
10 "sync"
11 "testing"
12 )
13
14 // fakeTTServation zeichnet einen Request am Fake-Server auf.
15 type recordedRequest struct {
16 method string
17 path string
18 header http.Header
19 body []byte
20 }
21
22 // fakeTTSServer ist der httptest-Fake eines Cloud-Providers: antwortet mit
23 // status + body und zeichnet den (einzigen) gesehenen Request auf.
24 func fakeTTSServer(t *testing.T, status int, body []byte) (*httptest.Server, *recordedRequest) {
25 t.Helper()
26 rec := &recordedRequest{}
27 var mu sync.Mutex
28 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
29 body2, err := io.ReadAll(r.Body)
30 if err != nil {
31 t.Errorf("Fake-Server: Body lesen: %v", err)
32 }
33 mu.Lock()
34 rec.method, rec.path, rec.header, rec.body = r.Method, r.URL.Path, r.Header.Clone(), body2
35 mu.Unlock()
36 w.WriteHeader(status)
37 w.Write(body)
38 }))
39 t.Cleanup(ts.Close)
40 return ts, rec
41 }
42
43 func envMap(kv map[string]string) func(string) string {
44 return func(k string) string { return kv[k] }
45 }
46
47 // fakeMP3 ist eine beliebige Byte-Folge — die Provider geben Antwort-Bytes
48 // unverändert zurück, der audio-Sink dekodiert (eigene Tests).
49 var fakeMP3 = []byte("\xff\xfb\x90\x00" + "audio-bytes-von-elf")
50
51 // TestElevenLabsSynthese200: 200 mit Bytes → Bytes kommen unverändert
52 // zurück; Methode, Pfad (inkl. Voice-ID), Header (xi-api-key) und der
53 // JSON-Body (model_id, voice_settings) entsprechen dem Task-Brief.
54 func TestElevenLabsSynthese200(t *testing.T) {
55 ts, rec := fakeTTSServer(t, 200, fakeMP3)
56 p := NewElevenLabs(ChainConfig{
57 Getenv: envMap(map[string]string{
58 EnvElevenLabsKey: "testkey",
59 EnvElevenLabsEndpoint: ts.URL,
60 }),
61 })
62
63 data, err := p.Synthesize(context.Background(), "Hallo Welt", "de")
64 if err != nil {
65 t.Fatalf("Synthesize: %v", err)
66 }
67 if string(data) != string(fakeMP3) {
68 t.Errorf("Synthesize = %q, want Fake-Bytes %q", data, fakeMP3)
69 }
70 if rec.method != http.MethodPost {
71 t.Errorf("Methode = %s, want POST", rec.method)
72 }
73 if want := "/v1/text-to-speech/21m00Tcm4TlvDq8ikWAM"; rec.path != want {
74 t.Errorf("Pfad = %q, want %q (neutrales Repo-Default „Rachel“)", rec.path, want)
75 }
76 if got := rec.header.Get("xi-api-key"); got != "testkey" {
77 t.Errorf("xi-api-key = %q, want %q", got, "testkey")
78 }
79
80 var body struct {
81 Text string `json:"text"`
82 ModelID string `json:"model_id"`
83 VoiceSettings struct {
84 Stability float64 `json:"stability"`
85 SimilarityBoost float64 `json:"similarity_boost"`
86 } `json:"voice_settings"`
87 }
88 if err := json.Unmarshal(rec.body, &body); err != nil {
89 t.Fatalf("Antwort-Body ist kein JSON: %v (%s)", err, rec.body)
90 }
91 if body.Text != "Hallo Welt" {
92 t.Errorf("text = %q", body.Text)
93 }
94 if body.ModelID != "eleven_multilingual_v2" {
95 t.Errorf("model_id = %q, want eleven_multilingual_v2", body.ModelID)
96 }
97 if body.VoiceSettings.Stability != 0.5 || body.VoiceSettings.SimilarityBoost != 0.75 {
98 t.Errorf("voice_settings = %+v, want stability 0.5 / similarity_boost 0.75", body.VoiceSettings)
99 }
100 }
101
102 // TestElevenLabsHTTPFehler: 500 und 401 sind klare Fehler, die Status und
103 // einen Body-Ausschnitt nennen (die Kette fällt daran weiter).
104 func TestElevenLabsHTTPFehler(t *testing.T) {
105 cases := []struct {
106 status int
107 body string
108 want string
109 }{
110 {500, "internal boom", "500"},
111 {401, `{"detail":{"status":"invalid_api_key"}}`, "401"},
112 }
113 for _, tc := range cases {
114 t.Run(http.StatusText(tc.status), func(t *testing.T) {
115 ts, _ := fakeTTSServer(t, tc.status, []byte(tc.body))
116 p := NewElevenLabs(ChainConfig{Getenv: envMap(map[string]string{
117 EnvElevenLabsKey: "testkey",
118 EnvElevenLabsEndpoint: ts.URL,
119 })})
120 _, err := p.Synthesize(context.Background(), "Hallo", "de")
121 if err == nil || !strings.Contains(err.Error(), tc.want) {
122 t.Fatalf("Synthesize-Fehler = %v, will HTTP %s", err, tc.want)
123 }
124 if !strings.Contains(err.Error(), "elevenlabs") {
125 t.Errorf("Fehler soll den Provider nennen: %v", err)
126 }
127 })
128 }
129 }
130
131 // TestElevenLabsAvailable: ohne Key nicht verfügbar, mit Key verfügbar —
132 // ohne jeden Netz-Call.
133 func TestElevenLabsAvailable(t *testing.T) {
134 if NewElevenLabs(ChainConfig{Getenv: envMap(nil)}).Available(context.Background()) {
135 t.Error("Available ohne Key = true, want false")
136 }
137 p := NewElevenLabs(ChainConfig{Getenv: envMap(map[string]string{EnvElevenLabsKey: "k"})})
138 if !p.Available(context.Background()) {
139 t.Error("Available mit Key = false, want true")
140 }
141 }
142
143 // TestElevenLabsVoiceRangfolge: --voice-Pin schlägt config-Mapping, Mapping
144 // schlägt Repo-Default (beobachtbar am Request-Pfad).
145 func TestElevenLabsVoiceRangfolge(t *testing.T) {
146 mk := func(t *testing.T, voices map[string]string, pin string) (*ElevenLabs, *recordedRequest) {
147 t.Helper()
148 ts, rec := fakeTTSServer(t, 200, fakeMP3)
149 p := NewElevenLabs(ChainConfig{
150 Getenv: envMap(map[string]string{
151 EnvElevenLabsKey: "k",
152 EnvElevenLabsEndpoint: ts.URL,
153 }),
154 Voices: map[string]map[string]string{"elevenlabs": voices},
155 })
156 if pin != "" {
157 p.PinVoice(pin)
158 }
159 return p, rec
160 }
161
162 t.Run("config-Mapping schlägt Default", func(t *testing.T) {
163 p, rec := mk(t, map[string]string{"de": "AusConfig"}, "")
164 if _, err := p.Synthesize(context.Background(), "Hallo", "de"); err != nil {
165 t.Fatalf("Synthesize: %v", err)
166 }
167 if want := "/v1/text-to-speech/AusConfig"; rec.path != want {
168 t.Errorf("Pfad = %q, want %q", rec.path, want)
169 }
170 })
171 t.Run("Pin (--voice) schlägt config-Mapping", func(t *testing.T) {
172 p, rec := mk(t, map[string]string{"de": "AusConfig"}, "Gepinnt")
173 if _, err := p.Synthesize(context.Background(), "Hallo", "de"); err != nil {
174 t.Fatalf("Synthesize: %v", err)
175 }
176 if want := "/v1/text-to-speech/Gepinnt"; rec.path != want {
177 t.Errorf("Pfad = %q, want %q", rec.path, want)
178 }
179 })
180 t.Run("unbekannte Sprache fällt auf en-Default", func(t *testing.T) {
181 p, rec := mk(t, nil, "")
182 if _, err := p.Synthesize(context.Background(), "Hallo", "fr"); err != nil {
183 t.Fatalf("Synthesize: %v", err)
184 }
185 if want := "/v1/text-to-speech/21m00Tcm4TlvDq8ikWAM"; rec.path != want {
186 t.Errorf("Pfad = %q, want en-Default %q", rec.path, want)
187 }
188 })
189 }
190
191 // TestElevenLabsLeererText ist ein klarer Fehler ohne Request.
192 func TestElevenLabsLeererText(t *testing.T) {
193 ts, rec := fakeTTSServer(t, 200, fakeMP3)
194 p := NewElevenLabs(ChainConfig{Getenv: envMap(map[string]string{
195 EnvElevenLabsKey: "k",
196 EnvElevenLabsEndpoint: ts.URL,
197 })})
198 if _, err := p.Synthesize(context.Background(), "", "de"); err == nil {
199 t.Error("Synthesize(\"\") soll fehlschlagen")
200 }
201 if rec.method != "" {
202 t.Error("Bei leerem Text darf kein Request gehen")
203 }
204 }
205
internal/provider/httpclient.go --- Go
1 package provider
2
3 import (
4 "bytes"
5 "context"
6 "fmt"
7 "io"
8 "net/http"
9 "time"
10 )
11
12 // cloudTimeout ist das Budget für einen einzigen Synthese-Call eines
13 // Cloud-Providers (Aufbau + Anfrage + vollständige Antwort). Ansagen sind
14 // kurz; nach Ablauf greift der Fallback auf die nächste Ketten-Stufe.
15 const cloudTimeout = 45 * time.Second
16
17 // cloudClient ist der geteilte HTTP-Client der REST-Provider (mit Timeout,
18 // im Gegensatz zu http.DefaultClient). Bei Ausbleiben einer Antwort greift
19 // so der Ketten-Fallback statt einer Endlos-Blockade.
20 var cloudClient = &http.Client{Timeout: cloudTimeout}
21
22 // maxErrBody begrenzt, wie viel einer Fehler-Antwort in die Fehlermeldung
23 // übernommen wird (Diagnose ja, Spam nein).
24 const maxErrBody = 200
25
26 // postForBytes macht den gemeinsamen Kern der REST-Provider: POST von JSON
27 // an url mit Headern, erwartet 2xx und liefert die rohen Antwort-Bytes
28 // (Audio). Alles andere — non-2xx, Netz-Fehler, Timeouts, leere Antworten —
29 // ist ein klarer Fehler, an dem die Kette in die nächste Stufe fällt.
30 func postForBytes(ctx context.Context, client *http.Client, providerName, url string, headers map[string]string, body []byte) ([]byte, error) {
31 if client == nil {
32 client = cloudClient
33 }
34 // Eigenes Zeit-Budget setzen, falls der Aufrufer keins mitgibt — die
35 // Kette soll auch ohne fremden Deadline-Kontext weiterfallen können.
36 if _, hasDeadline := ctx.Deadline(); !hasDeadline {
37 var cancel context.CancelFunc
38 ctx, cancel = context.WithTimeout(ctx, cloudTimeout)
39 defer cancel()
40 }
41
42 req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
43 if err != nil {
44 return nil, fmt.Errorf("%s: Anfrage an %s bauen: %w", providerName, url, err)
45 }
46 req.Header.Set("Content-Type", "application/json")
47 for k, v := range headers {
48 req.Header.Set(k, v)
49 }
50
51 resp, err := client.Do(req)
52 if err != nil {
53 return nil, fmt.Errorf("%s: Anfrage an %s fehlgeschlagen (Netz/Timeout?): %w", providerName, url, err)
54 }
55 defer resp.Body.Close()
56
57 if resp.StatusCode < 200 || resp.StatusCode > 299 {
58 snippet, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrBody))
59 return nil, fmt.Errorf("%s: HTTP %d von %s: %s", providerName, resp.StatusCode, url, bytes.TrimSpace(snippet))
60 }
61
62 data, err := io.ReadAll(resp.Body)
63 if err != nil {
64 return nil, fmt.Errorf("%s: Antwort von %s lesen: %w", providerName, url, err)
65 }
66 if len(data) == 0 {
67 return nil, fmt.Errorf("%s: leere Antwort von %s (0 Bytes Audio)", providerName, url)
68 }
69 return data, nil
70 }
71
internal/provider/openai.go --- Go
1 package provider
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "net/http"
8 "strings"
9 )
10
11 // OpenAI-TTS-Konstanten (öffentlicher Vertrag wie bei ElevenLabs).
12 const (
13 // DefaultEndpointOpenAI ist der produktive API-Endpunkt.
14 DefaultEndpointOpenAI = "https://api.openai.com"
15
16 // DefaultModelOpenAI: Entscheidungsnotiz aus dem Task-Brief — der
17 // Modell-String ist konfigurierbar ([providers.openai] model), Default
18 // "tts-1"; "gpt-4o-mini-tts" ist ein gültiger Alternativwert.
19 DefaultModelOpenAI = "tts-1"
20
21 EnvOpenAIKey = "TALK_OPENAI_KEY"
22 EnvOpenAIEndpoint = "TALK_OPENAI_ENDPOINT"
23 )
24
25 // OpenAI ist die zweite Stufe der Kette (für Nutzer mit vorhandenem
26 // OpenAI-Key). Synthesize führt POST <endpoint>/v1/audio/speech aus —
27 // Bearer-Key, Body {model, voice, input, response_format: "mp3"} — und
28 // liefert die Antwort-Bytes unverändert zurück. Rückgabeformat: MP3.
29 type OpenAI struct {
30 // Endpoint ist die Basis-URL (ohne Pfad); leer = Default beim Bau.
31 Endpoint string
32
33 // APIKey ist der OpenAI-Key (aus TALK_OPENAI_KEY).
34 APIKey string
35
36 // Model ist der TTS-Modell-String; leer = DefaultModelOpenAI.
37 Model string
38
39 // Voices ist das config-Mapping voice.openai.<lang> = id; leer =
40 // neutrale Repo-Defaults (alloy, siehe voices.go).
41 Voices map[string]string
42
43 // Client ist der HTTP-Client; nil = geteilter Client mit Timeout.
44 Client *http.Client
45
46 pinned string // --voice-Pin (VoicePinner)
47 }
48
49 // NewOpenAI baut den Provider aus der Ketten-Konfiguration: Endpunkt aus
50 // TALK_OPENAI_ENDPOINT > [providers.openai].endpoint > Default; Modell aus
51 // [providers.openai].model; Key ausschließlich aus TALK_OPENAI_KEY.
52 func NewOpenAI(cfg ChainConfig) *OpenAI {
53 return &OpenAI{
54 Endpoint: endpointFrom(cfg, "openai", EnvOpenAIEndpoint, DefaultEndpointOpenAI),
55 APIKey: cfg.Getenv(EnvOpenAIKey),
56 Model: cfg.Model,
57 Voices: cfg.Voices["openai"],
58 }
59 }
60
61 // Name implementiert Provider.
62 func (*OpenAI) Name() string { return "openai" }
63
64 // PinVoice implementiert VoicePinner (CLI-Flag --voice).
65 func (p *OpenAI) PinVoice(id string) { p.pinned = id }
66
67 // Available implementiert Provider: Key und Endpunkt müssen gesetzt sein
68 // (ohne Netz-Call, siehe ElevenLabs.Available).
69 func (p *OpenAI) Available(ctx context.Context) bool {
70 return p.APIKey != "" && p.Endpoint != ""
71 }
72
73 // openAIRequest ist der Anfrage-Body.
74 type openAIRequest struct {
75 Model string `json:"model"`
76 Voice string `json:"voice"`
77 Input string `json:"input"`
78 ResponseFormat string `json:"response_format"`
79 }
80
81 // Synthesize implementiert Provider. Rückgabeformat: MP3.
82 func (p *OpenAI) Synthesize(ctx context.Context, text, lang string) ([]byte, error) {
83 if text == "" {
84 return nil, fmt.Errorf("openai: leerer Text")
85 }
86 voice := resolveVoice("openai", lang, p.pinned, p.Voices)
87 if voice == "" {
88 return nil, fmt.Errorf("openai: keine Stimme für Sprache %q konfiguriert", lang)
89 }
90 model := p.Model
91 if model == "" {
92 model = DefaultModelOpenAI
93 }
94 body, err := json.Marshal(openAIRequest{
95 Model: model,
96 Voice: voice,
97 Input: text,
98 ResponseFormat: "mp3",
99 })
100 if err != nil {
101 return nil, fmt.Errorf("openai: Anfrage-Body bauen: %w", err)
102 }
103 url := strings.TrimRight(p.Endpoint, "/") + "/v1/audio/speech"
104 return postForBytes(ctx, p.Client, "openai", url,
105 map[string]string{"Authorization": "Bearer " + p.APIKey}, body)
106 }
107
internal/provider/openai_test.go --- Go
1 package provider
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "net/http"
8 "strings"
9 "testing"
10 )
11
12 // TestOpenAISynthese200: 200 mit Bytes → Bytes unverändert; POST
13 // /v1/audio/speech, Bearer-Key, Body mit model/voice/input/response_format
14 // laut Task-Brief (Default-Modell tts-1, Default-Stimme alloy).
15 func TestOpenAISynthese200(t *testing.T) {
16 ts, rec := fakeTTSServer(t, 200, fakeMP3)
17 p := NewOpenAI(ChainConfig{
18 Getenv: envMap(map[string]string{
19 EnvOpenAIKey: "sk-test",
20 EnvOpenAIEndpoint: ts.URL,
21 }),
22 })
23
24 data, err := p.Synthesize(context.Background(), "Build fertig", "en")
25 if err != nil {
26 t.Fatalf("Synthesize: %v", err)
27 }
28 if string(data) != string(fakeMP3) {
29 t.Errorf("Synthesize = %q, want Fake-Bytes", data)
30 }
31 if rec.method != http.MethodPost {
32 t.Errorf("Methode = %s, want POST", rec.method)
33 }
34 if want := "/v1/audio/speech"; rec.path != want {
35 t.Errorf("Pfad = %q, want %q", rec.path, want)
36 }
37 if got := rec.header.Get("Authorization"); got != "Bearer sk-test" {
38 t.Errorf("Authorization = %q, want Bearer sk-test", got)
39 }
40
41 var body struct {
42 Model string `json:"model"`
43 Voice string `json:"voice"`
44 Input string `json:"input"`
45 ResponseFormat string `json:"response_format"`
46 }
47 if err := json.Unmarshal(rec.body, &body); err != nil {
48 t.Fatalf("Body ist kein JSON: %v (%s)", err, rec.body)
49 }
50 if body.Model != "tts-1" {
51 t.Errorf("model = %q, want Default tts-1", body.Model)
52 }
53 if body.Voice != "alloy" {
54 t.Errorf("voice = %q, want Default alloy", body.Voice)
55 }
56 if body.Input != "Build fertig" {
57 t.Errorf("input = %q", body.Input)
58 }
59 if body.ResponseFormat != "mp3" {
60 t.Errorf("response_format = %q, want mp3", body.ResponseFormat)
61 }
62 }
63
64 // TestOpenAIHTTPFehler: 500 und 401 sind klare Fehler mit Status.
65 func TestOpenAIHTTPFehler(t *testing.T) {
66 for _, status := range []int{500, 401} {
67 t.Run(http.StatusText(status), func(t *testing.T) {
68 ts, _ := fakeTTSServer(t, status, []byte("boom"))
69 p := NewOpenAI(ChainConfig{Getenv: envMap(map[string]string{
70 EnvOpenAIKey: "sk-test",
71 EnvOpenAIEndpoint: ts.URL,
72 })})
73 _, err := p.Synthesize(context.Background(), "Hallo", "de")
74 if err == nil || !strings.Contains(err.Error(), fmt.Sprintf("%d", status)) {
75 t.Fatalf("Synthesize-Fehler = %v, will HTTP %d", err, status)
76 }
77 if !strings.Contains(err.Error(), "openai") {
78 t.Errorf("Fehler soll den Provider nennen: %v", err)
79 }
80 })
81 }
82 }
83
84 // TestOpenAIModelKonfigurierbar: [providers.openai].model ändert den
85 // Modell-String im Body (Entscheidungsnotiz: Default tts-1, Alternativen
86 // wie gpt-4o-mini-tts erlaubt).
87 func TestOpenAIModelKonfigurierbar(t *testing.T) {
88 ts, rec := fakeTTSServer(t, 200, fakeMP3)
89 p := NewOpenAI(ChainConfig{
90 Getenv: envMap(map[string]string{
91 EnvOpenAIKey: "sk-test",
92 EnvOpenAIEndpoint: ts.URL,
93 }),
94 Model: "gpt-4o-mini-tts",
95 })
96 if _, err := p.Synthesize(context.Background(), "Hallo", "de"); err != nil {
97 t.Fatalf("Synthesize: %v", err)
98 }
99 var body struct {
100 Model string `json:"model"`
101 }
102 if err := json.Unmarshal(rec.body, &body); err != nil {
103 t.Fatal(err)
104 }
105 if body.Model != "gpt-4o-mini-tts" {
106 t.Errorf("model = %q, want gpt-4o-mini-tts", body.Model)
107 }
108 }
109
110 // TestOpenAIAvailable: Key entscheidet — ohne Netz-Call.
111 func TestOpenAIAvailable(t *testing.T) {
112 if NewOpenAI(ChainConfig{Getenv: envMap(nil)}).Available(context.Background()) {
113 t.Error("Available ohne Key = true, want false")
114 }
115 p := NewOpenAI(ChainConfig{Getenv: envMap(map[string]string{EnvOpenAIKey: "sk"})})
116 if !p.Available(context.Background()) {
117 t.Error("Available mit Key = false, want true")
118 }
119 }
120
121 // TestOpenAIVoiceRangfolge: Pin schlägt Mapping schlägt Default.
122 func TestOpenAIVoiceRangfolge(t *testing.T) {
123 t.Run("Mapping schlägt Default", func(t *testing.T) {
124 ts, rec := fakeTTSServer(t, 200, fakeMP3)
125 p := NewOpenAI(ChainConfig{
126 Getenv: envMap(map[string]string{
127 EnvOpenAIKey: "sk",
128 EnvOpenAIEndpoint: ts.URL,
129 }),
130 Voices: map[string]map[string]string{"openai": {"en": "nova"}},
131 })
132 if _, err := p.Synthesize(context.Background(), "Hi", "en"); err != nil {
133 t.Fatalf("Synthesize: %v", err)
134 }
135 var body struct {
136 Voice string `json:"voice"`
137 }
138 json.Unmarshal(rec.body, &body)
139 if body.Voice != "nova" {
140 t.Errorf("voice = %q, want nova (config)", body.Voice)
141 }
142 })
143 t.Run("Pin schlägt Mapping", func(t *testing.T) {
144 ts, rec := fakeTTSServer(t, 200, fakeMP3)
145 p := NewOpenAI(ChainConfig{
146 Getenv: envMap(map[string]string{
147 EnvOpenAIKey: "sk",
148 EnvOpenAIEndpoint: ts.URL,
149 }),
150 Voices: map[string]map[string]string{"openai": {"en": "nova"}},
151 })
152 p.PinVoice("echo")
153 if _, err := p.Synthesize(context.Background(), "Hi", "en"); err != nil {
154 t.Fatalf("Synthesize: %v", err)
155 }
156 var body struct {
157 Voice string `json:"voice"`
158 }
159 json.Unmarshal(rec.body, &body)
160 if body.Voice != "echo" {
161 t.Errorf("voice = %q, want echo (--voice)", body.Voice)
162 }
163 })
164 }
165
internal/provider/osbuiltin_test.go --- Go
48 48
49 func TestNewRegistry(t *testing.T) { 49 func TestNewRegistry(t *testing.T) {
50 for _, name := range []string{"" 50 for _, name := range []string{""
.. , "osbuiltin"} { .. , "osbuiltin"} {
51 p, err := New(name) 51 p, err := New(name, ChainCon
.. .. fig{})
52 if err != nil { 52 if err != nil {
53 t.Fatalf("New(%q): %v", 53 t.Fatalf("New(%q): %v",
.. name, err) .. name, err)
54 } 54 }
55 if p.Name() != "osbuiltin" { 55 if p.Name() != "osbuiltin" {
56 t.Errorf("New(%q).Name() 56 t.Errorf("New(%q).Name()
.. = %q", name, p.Name()) .. = %q", name, p.Name())
57 } 57 }
58 } 58 }
.. 59 // Task 03: die Cloud-Provider s
.. .. tehen in der Registry.
.. 60 for _, name := range []string{"e
.. .. levenlabs", "openai", "edge"} {
.. 61 p, err := New(name, ChainCon
.. .. fig{})
.. 62 if err != nil {
.. 63 t.Fatalf("New(%q): %v",
.. .. name, err)
.. 64 }
.. 65 if p.Name() != name {
.. 66 t.Errorf("New(%q).Name()
.. .. = %q", name, p.Name())
.. 67 }
.. 68 }
59 if _, err := New("elevenlabs"); 69 if _, err := New("gibtsnicht", C
.. err == nil { .. hainConfig{}); err == nil {
60 t.Error("New(\"elevenlabs\") 70 t.Error("New(\"gibtsnicht\")
.. soll in diesem Stand fehlschlagen ( .. soll fehlschlagen")
.. Task 03 ergänzt)") ..
61 } else if !strings.Contains(err. 71 } else if !strings.Contains(err.
.. Error(), "osbuiltin") { .. Error(), "osbuiltin") || !strings.Co
.. .. ntains(err.Error(), "elevenlabs") {
62 t.Errorf("New-Fehler soll ve 72 t.Errorf("New-Fehler soll ve
.. rfügbare Provider nennen: %v", err) .. rfügbare Provider nennen: %v", err)
63 } 73 }
64 } 74 }
internal/provider/provider.go --- 1/4 --- Go
8 "strings" 8 "strings"
9 ) 9 )
10 10
11 // DefaultName ist der Provider, de 11 // DefaultName ist der Provider, de
.. n die Kette nutzt, wenn kein --prov .. n New("") liefert (Einzelkonstrukti
.. ider .. on,
12 // gepinnt ist. Task 02: osbuiltin; 12 // z. B. für doctor). Die Default-A
.. Task 03 ersetzt den Default durch .. nsage-Kette ab Task 03 steht in
.. die ..
13 // Fallback-Kette (ElevenLabs → Ope 13 // DefaultOrder (chain.go): elevenl
.. nAI → Edge → osbuiltin). .. abs → openai → edge → osbuiltin.
14 const DefaultName = "osbuiltin" 14 const DefaultName = "osbuiltin"
15 15
16 // ErrNoByteOutput meldet, dass die 16 // ErrNoByteOutput meldet, dass die
.. Plattform keine über Synthesize .. Plattform keine über Synthesize
internal/provider/provider.go --- 2/4 --- Go
24 // Provider ist der Vertrag aller T 24 // Provider ist der Vertrag aller T
.. TS-Backends von talk. .. TS-Backends von talk.
25 // 25 //
26 // Synthesize liefert dekodierbare 26 // Synthesize liefert dekodierbare
.. Audio-Bytes; das Format ist von der .. Audio-Bytes; das Format ist von der
27 // Implementierung dokumentiert (os 27 // Implementierung dokumentiert: os
.. builtin: RIFF/WAVE mit PCM — von al .. builtin liefert RIFF/WAVE (PCM), di
.. len .. e
28 // Sinks der Aufgabe abspielbar; Cl 28 // Cloud-Provider elevenlabs, opena
.. oud-Provider ab Task 03: mp3). .. i und edge liefern MP3 — beides wir
.. .. d vom
.. 29 // audio-Paket (oto-Sink) verstande
.. .. n.
29 type Provider interface { 30 type Provider interface {
30 // Name liefert den eindeutigen 31 // Name liefert den eindeutigen
.. Providernamen (CLI --provider, Con .. Providernamen (CLI --provider, Con
.. fig). .. fig).
31 Name() string 32 Name() string
32 33
33 // Available meldet, ob der Pro 34 // Available meldet, ob der Pro
.. vider auf diesem System überhaupt n .. vider auf diesem System überhaupt n
.. utzbar .. utzbar
34 // ist (z. B. Binary oder Key v 35 // ist (z. B. Binary oder Key v
.. orhanden). Muss billig und ohne Neb .. orhanden). Muss billig und ohne Neb
.. en- .. en-
35 // wirkungen sein; false heißt: 36 // wirkungen sein; false heißt:
.. Kette soll übersprungen werden. .. Kette soll überspringen werden.
36 Available(ctx context.Context) 37 Available(ctx context.Context)
.. bool .. bool
37 38
38 // Synthesize erzeugt Audio-Byt 39 // Synthesize erzeugt Audio-Byt
.. es für text in der Sprache lang. .. es für text in der Sprache lang.
internal/provider/provider.go --- 3/4 --- Go
46 // Audio-Bytes an Prozesse liefern 47 // Audio-Bytes an Prozesse liefern
.. kann — dort spielt osbuiltin.SpeakD .. kann — dort spielt osbuiltin.SpeakD
.. irect .. irect
47 // über spd-say selbst ab (blockier 48 // über spd-say selbst ab (blockier
.. end bis zum Ende der Ansage). .. end bis zum Ende der Ansage).
48 // 49 //
49 // Task 03+ muss diese Erweiterung 50 // In der Kette (chain.go) ist Spea
.. beachten: In der Kette ist SpeakDir .. kDirect die letzte Stufe, wenn kein
.. ect ..
50 // die letzte Stufe, wenn kein Prov 51 // Provider Bytes liefern kann; Cha
.. ider Bytes liefern kann. .. in implementiert DirectSpeaker selb
.. .. st und
.. 52 // reicht an den Stufen-Provider du
.. .. rch.
51 type DirectSpeaker interface { 53 type DirectSpeaker interface {
52 // SpeakDirect gibt text in der 54 // SpeakDirect gibt text in der
.. Sprache lang direkt über das OS-TT .. Sprache lang direkt über das OS-TT
.. S aus .. S aus
53 // und blockiert bis zum Ende d 55 // und blockiert bis zum Ende d
.. er Wiedergabe (oder bis ctx abgebro .. er Wiedergabe (oder bis ctx abgebro
.. chen .. chen
54 // wird). 56 // wird).
55 SpeakDirect(ctx context.Context 57 SpeakDirect(ctx context.Context
.. , text, lang string) error .. , text, lang string) error
56 } 58 }
.. 59
.. 60 // VoicePinner ist eine dokumentier
.. .. te optionale Erweiterung über das
.. 61 // Provider-Interface hinaus: Provi
.. .. der, die eine explizite Voice-ID pi
.. .. nnen
.. 62 // können (das CLI-Flag --voice). D
.. .. ie gepinnte Stimme schlägt das
.. 63 // voice.<provider>.<lang>-Mapping
.. .. der config.toml und die eingebauten
.. 64 // neutralen Defaults. Die Kette ru
.. .. ft PinVoice beim Bau auf alle Stufe
.. .. n auf,
.. 65 // die die Erweiterung implementier
.. .. en (Stufen ohne Voice-Begriff, z. B
.. .. .
.. 66 // osbuiltin, loggen einen Hinweis
.. .. statt still zu ignorieren).
.. 67 type VoicePinner interface {
.. 68 PinVoice(id string)
.. 69 }
57 70
58 // registry bildet Providernamen au 71 // registry bildet Providernamen au
.. f Konstruktoren; Task 03 ergänzt hi .. f Konstruktoren; NewChain und New b
.. er .. auen
59 // elevenlabs, openai und edge. 72 // darüber. Die Konstruktoren erhal
.. .. ten die ChainConfig (Endpunkte, Voi
.. .. ces,
.. 73 // Keys via Getenv), damit Kette un
.. .. d Daemon (Task 04) dieselbe Quelle
.. .. haben.
60 var registry = map[string]func() Pr 74 var registry = map[string]func(Chai
.. ovider{ .. nConfig) Provider{
.. 75 "elevenlabs": func(cfg ChainCon
.. .. fig) Provider { return NewElevenLab
.. .. s(cfg) },
.. 76 "openai": func(cfg ChainCon
.. .. fig) Provider { return NewOpenAI(cf
.. .. g) },
.. 77 "edge": func(cfg ChainCon
.. .. fig) Provider { return NewEdge(cfg)
.. .. },
61 "osbuiltin": func() Provider { 78 "osbuiltin": func(ChainConfig)
.. return OSBuiltin{} }, .. Provider { return OSBuiltin{} },
62 } 79 }
63 80
64 // Names liefert die registrierten 81 // Names liefert die registrierten
.. Providernamen (sortiert). .. Providernamen (sortiert).
internal/provider/provider.go --- 4/4 --- Go
71 return names 88 return names
72 } 89 }
73 90
74 // New liefert den Provider mit dem 91 // New liefert den Provider mit dem
.. angegebenen Namen; "" wählt den De .. angegebenen Namen (Einzelkonstrukt
.. fault. .. ion);
75 // Unbekannte (oder in diesem Stand 92 // "" wählt DefaultName. Für die An
.. noch nicht implementierte) Namen s .. sage-Kette (Fallback) NewChain verw
.. ind .. enden.
76 // ein klarer Fehler. 93 // Unbekannte Namen sind ein klarer
.. .. Fehler.
77 func New(name string) (Provider, er 94 func New(name string, cfg ChainConf
.. ror) { .. ig) (Provider, error) {
78 if name == "" { 95 if name == "" {
79 name = DefaultName 96 name = DefaultName
80 } 97 }
81 ctor, ok := registry[name] 98 ctor, ok := registry[name]
82 if !ok { 99 if !ok {
83 return nil, fmt.Errorf("unb 100 return nil, fmt.Errorf("unb
.. ekannter oder noch nicht implementi ... ekannter oder noch nicht implementi
.. erter Provider %q (in diesem Stand ... erter Provider %q (verfügbar: %s)",
.. verfügbar: %s)", ...
84 name, strings.Join(Name 101 name, strings.Join(Name
.. s(), ", ")) ... s(), ", "))
85 } 102 }
.. 103 if cfg.Getenv == nil {
.. 104 cfg.Getenv = func(string) s
.. ... tring { return "" }
.. 105 }
86 return ctor(), nil 106 return ctor(cfg), nil
87 } 107 }
internal/provider/voices.go --- Go
1 package provider
2
3 import "sort"
4
5 // voiceDefaults sind die neutralen Repo-Defaults je Provider und Sprache
6 // (Teil des öffentlichen Vertrags: `talk voices` aus Task 08 listet sie,
7 // überschrieben werden sie durch voice.<provider>.<lang> in der config.toml
8 // oder das CLI-Flag --voice).
9 //
10 // Entscheidungsnotiz ElevenLabs-Stimme: Spec und Plan führen die persönliche
11 // Stimme KXxZd16DiBqt82nbarJx ausdrücklich als PRIVATEN Default des Nutzers
12 // („nicht Repo-Default", Spec Further Notes); der Repo-Default ist deshalb
13 // die neutrale, mehrsprachige Standardstimme „Rachel" (21m00Tcm4TlvDq8ikWAM
14 // aus dem öffentlichen ElevenLabs-Katalog). Die persönliche Stimme gehört in
15 // die eigene config.toml:
16 //
17 // [voice.elevenlabs]
18 // de = "KXxZd16DiBqt82nbarJx"
19 // en = "KXxZd16DiBqt82nbarJx"
20 //
21 // OpenAI „alloy" und die Edge-Neural-Stimmen (de-DE-KatjaNeural,
22 // en-US-AriaNeural) sind die jeweils neutralen Standardstimmen der APIs
23 // (Edge-Stimmen sind im Task-Brief vorgegeben).
24 var voiceDefaults = map[string]map[string]string{
25 "elevenlabs": {
26 "de": "21m00Tcm4TlvDq8ikWAM", // „Rachel", multilingual
27 "en": "21m00Tcm4TlvDq8ikWAM",
28 },
29 "openai": {
30 "de": "alloy",
31 "en": "alloy",
32 },
33 "edge": {
34 "de": "de-DE-KatjaNeural",
35 "en": "en-US-AriaNeural",
36 },
37 "osbuiltin": {
38 // osbuiltin mappt intern selbst (macOS Anna/Samantha, SAPI-Kultur,
39 // espeak -v <lang>) und kennt keine Voice-IDs — hier nur der
40 // Vollständigkeit wegen dokumentiert.
41 "de": "",
42 "en": "",
43 },
44 }
45
46 // resolveVoice liefert die Voice-ID für einen Provider und eine Sprache.
47 // Rangfolge: override (CLI-Flag --voice bzw. PinVoice) > Mapping aus der
48 // config.toml > neutrales Repo-Default > en-Default > alphabetisch erstes
49 // Default. Ein leerer Rückgabewert heißt: der Provider kennt für diese
50 // Sprache keine Stimme (osbuiltin) und entscheidet selbst.
51 func resolveVoice(name, lang, override string, cfgVoices map[string]string) string {
52 if override != "" {
53 return override
54 }
55 if v := cfgVoices[lang]; v != "" {
56 return v
57 }
58 def := voiceDefaults[name]
59 if v := def[lang]; v != "" {
60 return v
61 }
62 if v := def["en"]; v != "" {
63 return v
64 }
65 keys := make([]string, 0, len(def))
66 for k := range def {
67 keys = append(keys, k)
68 }
69 sort.Strings(keys)
70 for _, k := range keys {
71 if v := def[k]; v != "" {
72 return v
73 }
74 }
75 return ""
76 }
77
internal/provider/voices_test.go --- Go
1 package provider
2
3 import "testing"
4
5 // TestResolveVoiceRangfolge pinnt die dokumentierte Rangfolge
6 // --voice-Pin > config-Mapping > Repo-Default > en-Default.
7 func TestResolveVoiceRangfolge(t *testing.T) {
8 cases := []struct {
9 name string
10 provider string
11 lang string
12 override string
13 cfg map[string]string
14 want string
15 }{
16 {"Default edge de", "edge", "de", "", nil, "de-DE-KatjaNeural"},
17 {"Default edge en", "edge", "en", "", nil, "en-US-AriaNeural"},
18 {"Default elevenlabs de (neutral, nicht privat)", "elevenlabs", "de", "", nil, "21m00Tcm4TlvDq8ikWAM"},
19 {"Default openai en", "openai", "en", "", nil, "alloy"},
20 {"Mapping schlägt Default", "edge", "de", "", map[string]string{"de": "de-DE-ConradNeural"}, "de-DE-ConradNeural"},
21 {"Pin schlägt Mapping", "edge", "de", "Gepinnt", map[string]string{"de": "de-DE-ConradNeural"}, "Gepinnt"},
22 {"unbekannte Sprache → en-Default", "edge", "fr", "", nil, "en-US-AriaNeural"},
23 {"osbuiltin kennt keine Voice-IDs", "osbuiltin", "de", "", nil, ""},
24 }
25 for _, tc := range cases {
26 t.Run(tc.name, func(t *testing.T) {
27 if got := resolveVoice(tc.provider, tc.lang, tc.override, tc.cfg); got != tc.want {
28 t.Errorf("resolveVoice = %q, want %q", got, tc.want)
29 }
30 })
31 }
32 }
33