package voice import ( "bytes" "context" "encoding/base64" "encoding/json" "fmt" "io" "log/slog" "net/http" "strings" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" ) // cloudMaxResponseBytes 限制读取云端语音响应的体积,使过大 // 或畸形的响应无法耗尽内存。请求超时 // 约束单次调用。 const ( cloudMaxResponseBytes = 8 << 20 // maxErrorBodyBytes 是非 2xx 响应(错误体)的读取上限。它与 // cloudMaxResponseBytes 是不同口径:那个是数据面(音频/JSON 响应)的预算, // 这一条是攻击面的预算 —— 错误体只用来数长度 / 取 sha256 前缀 / 抽 // code 与 request_id,因此 4 KiB 足够,绝不整包读进来。 maxErrorBodyBytes = 4 << 10 cloudHTTPTimeout = 180 * time.Second ) // HTTPSTT 通过向云端 endpoint POST 一个 JSON 请求体并 // 读回 {"text": "..."} 来转写音频。它是桩式的默认 STT 实现; // 真正面向 DashScope/whisper 的适配器是一个适配点。 type HTTPSTT struct { endpoint string apiKey string client *http.Client log *slog.Logger } // HTTPTTS 通过 POST 一个 JSON 请求体并读取返回的 // base64 音频来合成语音。它是桩式的默认 TTS 实现。 type HTTPTTS struct { endpoint string apiKey string client *http.Client log *slog.Logger } // HTTPOption 定制 HTTPSTT/HTTPTTS 的 client 与日志器。 type HTTPOption func(*httpConfig) type httpConfig struct { client *http.Client log *slog.Logger } // WithHTTPClient 设置该调用所用的 *http.Client;nil 表示保持默认。 func WithHTTPClient(c *http.Client) HTTPOption { return func(o *httpConfig) { if c != nil { o.client = c } } } // WithLogger 设置失败时使用的日志器;nil 表示保持丢弃日志器。 func WithLogger(l *slog.Logger) HTTPOption { return func(o *httpConfig) { if l != nil { o.log = l } } } // NewHTTPSTT 为给定 endpoint 与 API key 构造一个 HTTPSTT。 func NewHTTPSTT(endpoint, apiKey string, opts ...HTTPOption) *HTTPSTT { h := &httpConfig{client: defaultHTTPClient(), log: discardLogger()} for _, o := range opts { o(h) } return &HTTPSTT{endpoint: strings.TrimRight(endpoint, "/"), apiKey: apiKey, client: h.client, log: h.log} } // NewHTTPTTS 为给定 endpoint 与 API key 构造一个 HTTPTTS。 func NewHTTPTTS(endpoint, apiKey string, opts ...HTTPOption) *HTTPTTS { h := &httpConfig{client: defaultHTTPClient(), log: discardLogger()} for _, o := range opts { o(h) } return &HTTPTTS{endpoint: strings.TrimRight(endpoint, "/"), apiKey: apiKey, client: h.client, log: h.log} } // sttRequest 是发往 STT endpoint 的 JSON 请求体。 type sttRequest struct { Audio string `json:"audio"` Format string `json:"format"` } // sttResponse 是 STT endpoint 返回的转写。 type sttResponse struct { Text string `json:"text"` } // STT 发送音频并返回识别出的文字。endpoint 为空时返回 // ErrNotConfigured,链随之降级。 func (s *HTTPSTT) STT(ctx context.Context, audio Audio) (string, error) { if s.endpoint == "" { return "", ErrNotConfigured } if err := ctx.Err(); err != nil { return "", err } payload, err := json.Marshal(sttRequest{Audio: base64.StdEncoding.EncodeToString(audio.Data), Format: audio.Format}) if err != nil { return "", fmt.Errorf("voice: %w: marshal: %v", ErrProviderFailed, err) } resp, err := postJSON(ctx, s.client, s.apiKey, s.endpoint, payload) if err != nil { return "", err } defer func() { _ = resp.Body.Close() }() var decoded sttResponse if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil { return "", fmt.Errorf("voice: %w: decode: %v", ErrProviderFailed, err) } return decoded.Text, nil } // ttsRequest 是发往 TTS endpoint 的 JSON 请求体。 type ttsRequest struct { Text string `json:"text"` } // ttsResponse 是 TTS endpoint 返回的合成音频。 type ttsResponse struct { Audio string `json:"audio"` Format string `json:"format"` } // TTS 发送文本并返回解码后的音频。endpoint 为空时返回 // ErrNotConfigured,链随之降级。 func (s *HTTPTTS) TTS(ctx context.Context, text string) (Audio, error) { if s.endpoint == "" { return Audio{}, ErrNotConfigured } if strings.TrimSpace(text) == "" { return Audio{}, ErrEmptyText } if err := ctx.Err(); err != nil { return Audio{}, err } payload, err := json.Marshal(ttsRequest{Text: text}) if err != nil { return Audio{}, fmt.Errorf("voice: %w: marshal: %v", ErrProviderFailed, err) } resp, err := postJSON(ctx, s.client, s.apiKey, s.endpoint, payload) if err != nil { return Audio{}, err } defer func() { _ = resp.Body.Close() }() var decoded ttsResponse if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil { return Audio{}, fmt.Errorf("voice: %w: decode: %v", ErrProviderFailed, err) } data, err := base64.StdEncoding.DecodeString(decoded.Audio) if err != nil { return Audio{}, fmt.Errorf("voice: %w: decode audio: %v", ErrProviderFailed, err) } format := decoded.Format if format == "" { format = "mp3" } return Audio{Format: format, Data: data}, nil } // postJSON POST 一个 JSON 载荷,接好鉴权并检查状态码。 func postJSON(ctx context.Context, client *http.Client, apiKey, url string, payload []byte) (*http.Response, error) { req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) if err != nil { return nil, fmt.Errorf("voice: %w: build: %v", ErrProviderFailed, trace.URLCause(err)) } req.Header.Set("Content-Type", "application/json") if apiKey != "" { req.Header.Set("Authorization", "Bearer "+apiKey) } resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("voice: %w: request: %v", ErrProviderFailed, trace.URLCause(err)) } if resp.StatusCode != http.StatusOK { _ = resp.Body.Close() return nil, fmt.Errorf("voice: %w: status %d", ErrProviderFailed, resp.StatusCode) } return resp, nil } // defaultHTTPClient 构造一个带请求超时的 client。这里每次调用 // 返回新 client,而不是包级全局变量——后者为约束所禁止。 func defaultHTTPClient() *http.Client { return &http.Client{Transport: http.DefaultTransport, Timeout: cloudHTTPTimeout} } // discardLogger 返回一个丢弃全部输出的日志器。 func discardLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } // newDashboardSTT 为 DashScope 配置返回云端 STT 适配器。未设置 // API key 时调用会返回 ErrNotConfigured,链随之降级。 func newDashboardSTT(cfg config.VoiceConfig) STT { return NewDashscopeSTT(cfg) } // newDashboardTTS 为 DashScope 配置返回云端 TTS 适配器。 func newDashboardTTS(cfg config.VoiceConfig) TTS { return NewDashscopeTTS(cfg) } // newDashboardCloner 为 DashScope 配置返回云端音色注册 cloner, // 供 create_voice(声音复刻)endpoint 使用。 func newDashboardCloner(cfg config.VoiceConfig) VoiceCloner { return NewDashscopeCloner(cfg) } // newEdgeTTS 返回 edge-tts 适配点(尚未实现)。 func newEdgeTTS() TTS { return notImplementedTTS{} } // newEspeak 返回 espeak 适配点(尚未实现)。 func newEspeak() TTS { return notImplementedTTS{} } // notImplementedTTS 是一个总是降级的适配点。 type notImplementedTTS struct{} func (notImplementedTTS) TTS(_ context.Context, _ string) (Audio, error) { return Audio{}, ErrNotImplemented }