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