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" ) // cloudMaxResponseBytes caps how much of a cloud voice response is read, so an // oversized or malformed response cannot exhaust memory. The request timeout // bounds a single call. const ( cloudMaxResponseBytes = 8 << 20 cloudHTTPTimeout = 180 * time.Second ) // HTTPSTT transcribes audio by POSTing a JSON body to a cloud endpoint and // reading {"text": "..."} back. It is the stubbed default STT implementation; // the real DashScope/whisper-facing adapter is an adaptation point. type HTTPSTT struct { endpoint string apiKey string client *http.Client log *slog.Logger } // HTTPTTS synthesizes speech by POSTing a JSON body and reading the returned // base64 audio. It is the stubbed default TTS implementation. type HTTPTTS struct { endpoint string apiKey string client *http.Client log *slog.Logger } // HTTPOption customizes the client and logger of an HTTPSTT/HTTPTTS. type HTTPOption func(*httpConfig) type httpConfig struct { client *http.Client log *slog.Logger } // WithHTTPClient sets the *http.Client used for the call; nil keeps the default. func WithHTTPClient(c *http.Client) HTTPOption { return func(o *httpConfig) { if c != nil { o.client = c } } } // WithLogger sets the logger used on failures; nil keeps a discard logger. func WithLogger(l *slog.Logger) HTTPOption { return func(o *httpConfig) { if l != nil { o.log = l } } } // NewHTTPSTT builds an HTTPSTT for the given endpoint and API key. 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 builds an HTTPTTS for the given endpoint and API key. 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 is the JSON body sent to the STT endpoint. type sttRequest struct { Audio string `json:"audio"` Format string `json:"format"` } // sttResponse is the transcription returned by the STT endpoint. type sttResponse struct { Text string `json:"text"` } // STT posts the audio and returns the recognized text. A blank endpoint returns // ErrNotConfigured so the chain degrades. 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 is the JSON body sent to the TTS endpoint. type ttsRequest struct { Text string `json:"text"` } // ttsResponse is the synthesized audio returned by the TTS endpoint. type ttsResponse struct { Audio string `json:"audio"` Format string `json:"format"` } // TTS posts the text and returns the decoded audio. A blank endpoint returns // ErrNotConfigured so the chain degrades. 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 POSTs a JSON payload, wires auth, and checks the status code. 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, 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, err) } if resp.StatusCode != http.StatusOK { _ = resp.Body.Close() return nil, fmt.Errorf("voice: %w: status %d", ErrProviderFailed, resp.StatusCode) } return resp, nil } // defaultHTTPClient builds a client with a request timeout. A fresh client is // returned per call rather than a package global, which the constraints forbid. func defaultHTTPClient() *http.Client { return &http.Client{Transport: http.DefaultTransport, Timeout: cloudHTTPTimeout} } // discardLogger returns a logger that drops all output. func discardLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } // newDashboardSTT returns the cloud STT adapter for a DashScope config. A call // returns ErrNotConfigured when no API key is set so the chain degrades. func newDashboardSTT(cfg config.VoiceConfig) STT { return NewDashscopeSTT(cfg) } // newDashboardTTS returns the cloud TTS adapter for a DashScope config. func newDashboardTTS(cfg config.VoiceConfig) TTS { return NewDashscopeTTS(cfg) } // newDashboardCloner returns the cloud voice-enrollment cloner for a DashScope // config, used by the create_voice (声音复刻) endpoint. func newDashboardCloner(cfg config.VoiceConfig) VoiceCloner { return NewDashscopeCloner(cfg) } // newEdgeTTS returns the edge-tts adapter point (not yet implemented). func newEdgeTTS() TTS { return notImplementedTTS{} } // newEspeak returns the espeak adapter point (not yet implemented). func newEspeak() TTS { return notImplementedTTS{} } // notImplementedTTS is an adaptation point that always degrades. type notImplementedTTS struct{} func (notImplementedTTS) TTS(_ context.Context, _ string) (Audio, error) { return Audio{}, ErrNotImplemented }