# 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=` oder .. .. `provider = ""` 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.] 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>.` > 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 ` ... nsausgabe: `talk 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.. = 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.]`). 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..-Mapping aus der config.toml. 45 Voices map[string]map[string]string 46 47 // Endpoints sind die Datei-Endpunkte [providers.].endpoint; 48 // TALK__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__ENDPOINT (Env) > [providers.].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. = 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 // „-" (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 ""+ 301 ""+ 302 "%s"+ 303 "", 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, "&"...) 323 case '<': 324 esc = append(esc, "<"...) 325 case '>': 326 esc = append(esc, ">"...) 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 „fertig“") 67 for _, want := range []string{ 68 "voice name='de-DE-KatjaNeural'", 69 "xml:lang='en-US'", 70 "AT&T <Kunde>", 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\x08e" 85 if want := "a b&c<d> 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 & 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 // /v1/text-to-speech/ 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. = 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 /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. = 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..-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.. 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 ) 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