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

3740 lines
173 KiB
Diff
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Review package: 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