Repository navigation
Expand file tree
/
Copy pathtts.go
More file actions
260 lines (240 loc) · 9.1 KB
/
Copy pathtts.go
File metadata and controls
260 lines (240 loc) · 9.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
package llm
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// ── TTS (text-to-speech) ─────────────────────────────────────────────────
//
// Speak turns text into audio bytes. Two wire formats are supported:
// - OpenAI-compatible: POST {base}/audio/speech, binary audio response
// - Gemini: generateContent with AUDIO response modality, base64 inlineData
//
// Any other format (anthropic, …) is a ConfigError. The SDK never
// transcodes: it returns the provider's bytes plus the MIME type the
// provider declared (or a well-known default when the provider omits it).
// API keys never appear in errors — the invariants of chat apply unchanged.
// SpeakRequest describes one text-to-speech conversion.
type SpeakRequest struct {
Headers map[string]string // per-request headers (SL03)
Text string // required, non-empty
Voice string // provider voice id (no fallback tables — invariant #5)
Format string // container for OpenAI-compat providers ("mp3" default)
Speed float64 // optional playback speed, OpenAI-compat only (0 = omit)
}
// SpeakResult carries the synthesized audio.
type SpeakResult struct {
Audio []byte // raw audio bytes as the provider returned them
Model string // model that produced the audio
MIMEType string // provider-declared (or well-known default) MIME type
Meta ResponseMeta
}
// Speak synthesizes speech with the named provider and model. Buffered
// only — no streaming in v1 — with the same retry ladder as chat.
func (s *SDK) Speak(ctx context.Context, providerID, model string, req SpeakRequest) (*SpeakResult, error) {
if strings.TrimSpace(req.Text) == "" {
return nil, &ConfigError{Msg: "speak request requires non-empty Text"}
}
if strings.TrimSpace(model) == "" {
return nil, &ConfigError{Msg: "speak request requires a model"}
}
if strings.TrimSpace(req.Voice) == "" {
return nil, &ConfigError{Msg: "speak request requires a Voice (both supported wire formats require one server-side)"}
}
p, err := s.usableProvider(providerID)
if err != nil {
return nil, err
}
pc := s.newClient(p, s.timeout, nil)
return pc.speak(ctx, model, req)
}
// buildSpeakRequest dispatches format-specific serialization. The third
// return is the well-known MIME type used when the provider omits
// Content-Type ("" for formats with no such default).
func (pc *providerClient) buildSpeakRequest(model string, req SpeakRequest) ([]byte, string, string, error) {
switch pc.cfg.Format {
case FormatGemini:
return buildGeminiSpeakRequest(pc.base, model, req)
case FormatOpenAI:
format := req.Format
if format == "" {
format = "mp3"
}
body := map[string]any{
"model": model,
"input": req.Text,
"voice": req.Voice,
"response_format": format,
}
if req.Speed != 0 {
body["speed"] = req.Speed
}
b, err := json.Marshal(body)
return b, pc.base + "/audio/speech", "audio/mpeg", err
default:
return nil, "", "", &ConfigError{Msg: "provider format " + string(pc.cfg.Format) + " does not support speech"}
}
}
// speak runs the TTS request against one provider with the shared retry
// ladder and whole-call budget of the buffered chat path (binary body, no
// SSE).
func (pc *providerClient) speak(ctx context.Context, model string, req SpeakRequest) (*SpeakResult, error) {
ctx, herr := withReqHeaders(ctx, req.Headers)
if herr != nil {
return nil, herr
}
body, url, mimeHint, err := pc.buildSpeakRequest(model, req)
if err != nil {
return nil, err
}
gemini := pc.cfg.Format == FormatGemini
ctx = pc.withAttempts(ctx, "speak", model)
ctx, cancel := context.WithTimeout(ctx, pc.requestTimeout())
defer cancel()
var res *SpeakResult
err = pc.withRetry(ctx, func() (time.Duration, error) {
data, ctype, hdr, ra, err := pc.postAudio(ctx, url, body)
if err != nil {
return ra, err
}
if gemini {
audio, mime, perr := parseGeminiSpeakResponse(data)
if perr != nil {
// 2xx with no audio parts is a provider protocol
// failure — surface it through the typed error
// taxonomy (APIError at the actual HTTP status).
return 0, terminal(&APIError{
Provider: pc.cfg.ID,
Status: http.StatusOK,
Message: perr.Error(),
})
}
data, ctype = audio, mime
} else if strings.HasPrefix(strings.TrimSpace(ctype), "application/json") {
// OpenAI-compatible gateways sometimes answer 2xx with a
// JSON error envelope. Never hand JSON bytes back as
// audio: parse the envelope through the shared httpError
// path.
return 0, terminal(pc.httpError(http.StatusOK, data))
}
res = &SpeakResult{
Audio: data,
Model: model,
MIMEType: resolveAudioMIME(ctype, mimeHint),
Meta: parseResponseMeta(hdr),
}
return 0, nil
}, nil)
if err != nil {
return nil, err
}
return res, nil
}
// resolveAudioMIME prefers the provider's Content-Type; when absent it
// falls back to the wire format's well-known default (no guessing beyond
// that default — invariant #5).
func resolveAudioMIME(ctype, fallback string) string {
ctype = strings.TrimSpace(ctype)
if ctype != "" {
return ctype
}
return fallback
}
// postAudio sends one TTS request and reads the full binary body (capped).
// Unlike post it captures the response Content-Type and never assumes JSON.
func (pc *providerClient) postAudio(ctx context.Context, url string, body []byte) (data []byte, ctype string, hdr http.Header, ra time.Duration, err error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, "", nil, 0, &ConfigError{Msg: "build request: " + err.Error()}
}
req.Header.Set("Content-Type", "application/json")
pc.setHeaders(req.Header, reqHeadersFrom(ctx))
resp, err := doObserved(ctx, pc.buffered(), req)
if err != nil {
return nil, "", nil, 0, err
}
defer func() { _ = resp.Body.Close() }()
ctype = resp.Header.Get("Content-Type")
data, err = io.ReadAll(io.LimitReader(resp.Body, maxResponseSize+1))
if err != nil {
return nil, "", nil, 0, err
}
if len(data) > maxResponseSize {
return nil, "", nil, 0, terminal(fmt.Errorf("llm: audio response exceeds %d bytes", maxResponseSize))
}
ra = parseRetryAfter(resp.Header.Get("Retry-After"), time.Now())
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, "", nil, ra, pc.httpError(resp.StatusCode, data)
}
return data, ctype, resp.Header, ra, nil
}
// ── Gemini TTS wire types ────────────────────────────────────────────────
type geminiSpeakRequest struct {
Contents []gmContent `json:"contents"`
GenerationConfig geminiGenerationAudio `json:"generationConfig"`
}
type geminiGenerationAudio struct {
ResponseModalities []string `json:"responseModalities"`
SpeechConfig geminiSpeechCfg `json:"speechConfig"`
}
type geminiSpeechCfg struct {
VoiceConfig geminiVoiceCfg `json:"voiceConfig"`
}
type geminiVoiceCfg struct {
PrebuiltVoiceConfig geminiPrebuiltVoice `json:"prebuiltVoiceConfig"`
}
type geminiPrebuiltVoice struct {
VoiceName string `json:"voiceName"`
}
type geminiSpeakResponse struct {
Candidates []struct {
Content struct {
Parts []struct {
InlineData struct {
MimeType string `json:"mimeType"`
Data string `json:"data"`
} `json:"inlineData"`
} `json:"parts"`
} `json:"content"`
} `json:"candidates"`
}
// buildGeminiSpeakRequest serializes a native Gemini TTS request via
// generateContent: AUDIO response modality + prebuilt voice config. The
// response MIME comes from inlineData.mimeType, so there is no
// well-known fallback.
func buildGeminiSpeakRequest(base, model string, req SpeakRequest) ([]byte, string, string, error) {
greq := geminiSpeakRequest{
Contents: []gmContent{{Role: "user", Parts: []gmPart{{Text: req.Text}}}},
GenerationConfig: geminiGenerationAudio{
ResponseModalities: []string{"AUDIO"},
SpeechConfig: geminiSpeechCfg{VoiceConfig: geminiVoiceCfg{PrebuiltVoiceConfig: geminiPrebuiltVoice{VoiceName: req.Voice}}},
},
}
b, err := json.Marshal(greq)
return b, fmt.Sprintf("%s/v1beta/models/%s:generateContent", base, geminiModelPath(model)), "", err
}
// parseGeminiSpeakResponse decodes a buffered Gemini TTS response.
func parseGeminiSpeakResponse(data []byte) (audio []byte, mime string, err error) {
var resp geminiSpeakResponse
if err := json.Unmarshal(data, &resp); err != nil {
return nil, "", fmt.Errorf("llm: decode gemini speech response: %w", err)
}
if len(resp.Candidates) == 0 || len(resp.Candidates[0].Content.Parts) == 0 {
return nil, "", fmt.Errorf("llm: gemini speech response contained no audio parts")
}
inline := resp.Candidates[0].Content.Parts[0].InlineData
if inline.Data == "" {
return nil, "", fmt.Errorf("llm: gemini speech response contained no audio data")
}
audio, err = base64.StdEncoding.DecodeString(inline.Data)
if err != nil {
return nil, "", fmt.Errorf("llm: decode gemini speech audio: %w", err)
}
return audio, inline.MimeType, nil
}