package tts // 本文件是 emotionTTS 的 **HTTP 实现**:一次合成 = 一个 // `POST /v1/audio/speech`,回的是**原始音频字节**(不是 JSON 信封)。 // // 🔴🔴 **请求体必须是 UTF-8 JSON 字节**(本包最重要的一条约束) // // 该服务用 FastAPI/Pydantic 解 `application/json`;请求体里若出现**非 UTF-8** 的 // 中文字节,它会直接回 // `400 {"detail":"There was an error parsing the body"}`(塔已实测:Git Bash 内联 // 中文走 GBK 送过去就是这个错)。因此本文件**只**用 `encoding/json` 编码请求体 // (`json.Encoder` 天然产出 UTF-8 字节,且关掉 HTML 转义以免正文被改写)。 // // 以下写法**一律禁止**,改一处就会把中文打回 400: // - `fmt.Sprintf` / 字符串拼接拼 JSON 体; // - 把正文按 GBK/GB18030 转码后再发; // - 用 `text/template` 之类产出请求体。 // // `client_test.go` 里有一条**中文文本**的 httptest 用例专门钉这条:它既断言请求体 // 能被 UTF-8 解码、正文逐字相等,也断言字节里能直接搜到那段中文的 UTF-8 编码。 import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "log/slog" "net/http" "net/url" "strings" "sync/atomic" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" ) // 本包的哨兵错误。每个都以 %w 包装,调用方既能按类别判断,也拿得到原始原因。 var ( // ErrDisabled 报告这一级**未启用**(面板开关关着)。它不是故障:调用方应保持 // 既有链路不变,而不是把它当成"合成失败"去告警。 ErrDisabled = errors.New("voice/tts: emotionTTS provider is disabled") // ErrEmptyText 报告要合成的文本为空(没有声音可合成,不必打网络)。 ErrEmptyText = errors.New("voice/tts: empty text") // ErrUnreachable 报告**没拿到响应**:连接失败、超时、被取消。它与"上游拒了" // 刻意分开 —— 前者是"它没听见",后者是"它听见了但不同意",排查路径不同。 ErrUnreachable = errors.New("voice/tts: emotionTTS unreachable") // ErrUpstream 报告上游回了**非 200**(4xx/5xx)。错误里带状态码,但**不带正文** // (正文是她的回复,日志与错误链里都不该出现)。 ErrUpstream = errors.New("voice/tts: emotionTTS rejected the request") // ErrNotAudio 报告上游回了 200,但字节**认不出是音频**(多为 JSON/HTML 错误页)。 // 它是"拿到的不是我们要的东西",绝不能当成功落盘或下发。 ErrNotAudio = errors.New("voice/tts: upstream response is not audio") // ErrTooLarge 报告响应超过体积上限(见 defaultMaxAudioBytes)。 ErrTooLarge = errors.New("voice/tts: upstream response too large") ) // speechPayload 是 `/v1/audio/speech` 的请求体(OpenAITTSRequest 的子集, // 字段名逐字照上游 openapi.json)。 // // 🔴 **没有 `speed`**:上游把下游载荷的 speed 硬编码成 1.0 // (`core/utils/llm_provider.py` 的 `synthesize_audio`),传了也是空转,因此不暴露 // 假开关(见包注释)。也没有情绪/语气字段 —— 上游自己从 `input` 推。 type speechPayload struct { // Model 是固定模型名(上游只认 emotionTTS)。 Model string `json:"model"` // Input 是要合成的文本(UTF-8;见文件头的红线)。 Input string `json:"input"` // Voice 是角色 id(由会话 persona 映射而来,每次请求现算)。 Voice string `json:"voice"` // ResponseFormat 恒为 `wav`(`requestFormat`):上游产物无转码、我方只按字节 // 嗅探裁决,这个字段不可配置(见 requestFormat 的注释)。 ResponseFormat string `json:"response_format"` } // Stats 是一次累计的合成计数(进程内单调递增,用于面板/日志观测"降级了多少次")。 type Stats struct { // Attempts 是发起过的合成次数。 Attempts int64 // Failures 是失败次数(任何非 nil 错误的合成)。 Failures int64 } // Client 是 emotionTTS 的合成客户端。零值不可用,请用 New 构造。 type Client struct { cfg Config http *http.Client log *slog.Logger maxLen int64 attempts atomic.Int64 failures atomic.Int64 } // Option 定制 Client 的 HTTP 客户端、日志器与响应体积上限(测试用)。 type Option func(*Client) // WithLogger 注入带来源的日志器(既有 WARN 都带上它)。 func WithLogger(l *slog.Logger) Option { return func(c *Client) { c.log = l } } // WithHTTPClient 注入 HTTP 客户端(测试用短超时/自定义 Transport)。 // 注入后 `Config.Timeout` 不再生效 —— 超时由注入的客户端自己负责。 func WithHTTPClient(h *http.Client) Option { return func(c *Client) { c.http = h } } // WithMaxResponseBytes 覆盖响应体积上限(测试用)。 func WithMaxResponseBytes(n int64) Option { return func(c *Client) { c.maxLen = n } } // New 构造一个 emotionTTS 客户端。配置里的默认值由 `Config.Normalized` 补齐; // `Enabled=false` 时客户端仍然可用,但每次合成都会立刻回 `ErrDisabled`(这样 // "关掉开关"与"它挂了"在调用方那里是两种可区分的情形)。 func New(cfg Config, opts ...Option) *Client { c := &Client{log: slog.Default(), maxLen: defaultMaxAudioBytes} for _, o := range opts { o(c) } c.cfg = cfg.Normalized() if c.http == nil { c.http = &http.Client{Timeout: c.cfg.Timeout} } return c } // Config 返回**归一化之后**的实际生效配置(面板/日志据此如实展示)。 func (c *Client) Config() Config { return c.cfg } // Enabled 报告这一级是否启用。 func (c *Client) Enabled() bool { return c.cfg.Enabled } // RedactedBaseURL 返回**可安全打日志**的服务地址(只保留 scheme 与 host —— // 运维若在地址里塞了凭据,日志不该把它写出来)。 func (c *Client) RedactedBaseURL() string { return redactURL(c.cfg.BaseURL) } // Stats 返回当前计数快照。 func (c *Client) Stats() Stats { return Stats{Attempts: c.attempts.Load(), Failures: c.failures.Load()} } // Speech 把文本用**指定发音人**合成成音频。这是本包的**唯一网络出口**: // `Service`(降级包装)与既有语音链的 `TTS` 形状都走它,绝不存在第二处 HTTP 调用。 // // `character` 是**每次调用显式传入**的,客户端内部**不缓存**任何发音人 —— 因此 // 同一进程先后服务两个角色,第二次绝不会沿用第一次的 voice(主人 2026-10-26 的 // 硬要求;由 `Service` 按每次请求的 personaId 现算,见 selection.go)。 // 空 character 一律拒绝:宁可没有声音,也不要用一个"默认"角色的声音冒充她。 // // 返回值里的 `Format` 是**字节嗅探**所得,不是请求里那个 `response_format`。 func (c *Client) Speech(ctx context.Context, character, text string) (Audio, error) { if !c.cfg.Enabled { return Audio{}, ErrDisabled } voice := strings.TrimSpace(character) if voice == "" { return Audio{}, ErrNoCharacter } trimmed := strings.TrimSpace(text) if trimmed == "" { return Audio{}, ErrEmptyText } c.attempts.Add(1) audi, err := c.speak(ctx, voice, trimmed) if err != nil { c.failures.Add(1) } return audi, err } // speak 是 Speech 的主体:编码 → 发送 → 判状态 → 嗅探。失败原因已分类。 func (c *Client) speak(ctx context.Context, character, text string) (Audio, error) { body, err := encodeSpeech(text, character) if err != nil { return Audio{}, err } endpoint := c.cfg.BaseURL + speechPath req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) if err != nil { return Audio{}, fmt.Errorf("voice/tts: build request: %w", trace.URLCause(err)) } // Content-Type 必须是 application/json:正文本身已是 UTF-8(见文件头)。 req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "audio/*") res, err := c.http.Do(req) if err != nil { return Audio{}, c.unreachable(ctx, endpoint, err) } defer func() { _ = res.Body.Close() }() if res.StatusCode != http.StatusOK { return Audio{}, c.upstreamStatus(res) } return c.readAudio(res, text) } // encodeSpeech 把一次请求编码成 **UTF-8 JSON 字节**。 // // 🔴 全仓仅此一处构造请求体:`json.Encoder` 天然产出 UTF-8,且这里显式关掉 HTML // 转义,使正文里的 `<`/`>`/`&` 不会被改写成 `\u003c` 之类(内容逐字不变,下游 // 日志与音色匹配都依赖原文)。**绝不**改成字符串拼接或 GBK 转码(见文件头)。 func encodeSpeech(text, character string) ([]byte, error) { payload := speechPayload{ Model: modelName, Input: text, Voice: character, ResponseFormat: requestFormat, } var buf bytes.Buffer enc := json.NewEncoder(&buf) enc.SetEscapeHTML(false) if err := enc.Encode(&payload); err != nil { return nil, fmt.Errorf("voice/tts: encode request: %w", err) } return buf.Bytes(), nil } // unreachable 把"没拿到响应"收敛成 ErrUnreachable(超时/取消/连接失败都归这里), // 并记一条带来源的 WARN。正文**不进日志**。 // // 原因在**进日志与进错误之前**先过 trace.URLCause:`client.Do` 回来的 `*url.Error` // 会把整条 endpoint(含 userinfo / query 里的签名)渲染进错误串,而本函数把同一个 // err 既写日志又包进返回值 —— 只脱敏 endpoint 字段是不够的,那条 err 会把地址 // 原样带出去。因此 endpoint 只以 redactURL 的形态出现,err 只以「已剥壳的原因」出现 // (地址不外泄一遍,也不会两处各出现一遍)。 // // 分类仍按**原来的 err** 判断(不按剥壳后的 cause):本函数唯一的改变是「少一段地址」, // ErrUnreachable 与 context.* 哨兵链、WARN 的 reason 取值都不受影响。 func (c *Client) unreachable(ctx context.Context, endpoint string, err error) error { reason := "connection failed" switch { case ctx.Err() != nil: reason = "context canceled" case errors.Is(err, context.DeadlineExceeded): reason = "timeout" case errors.Is(err, context.Canceled): reason = "context canceled" } cause := trace.URLCause(err) c.warn("voice tts unreachable", "reason", reason, "endpoint", redactURL(endpoint), "err", cause) return fmt.Errorf("%w: %s: %w", ErrUnreachable, reason, cause) } // upstreamStatus 把非 200 收敛成 ErrUpstream,并只记**元数据**(状态码、Content-Type // 与响应字节数)—— 上游的错误体**一个字节都不进日志**。 // // 🔴 这条是隐私红线,不是风格问题:FastAPI 的 422 会把请求体里的 `input` **整段回显**, // 实测报文形如 // // {"detail":[{"type":"missing","loc":["body","voice"],…, // "input":{"input":"你好,我是苏梦,我有点难过。"}}]} // // 于是"把错误体前 512 字节打进 WARN"就等于**把她说的原话写进日志**。错误链里同样 // 只有状态码(`TestSpeechRejectLogOmitsRequestText` 钉这一条,变异可打红)。 // // 字节数优先取 `Content-Length`;缺失时只做**有界**排空(上限 maxDrainBytes)得出 // 一个下界,**不为数字节把整段读完**,读到的东西也绝不进日志。 func (c *Client) upstreamStatus(res *http.Response) error { size := res.ContentLength if size < 0 { drained, _ := io.Copy(io.Discard, io.LimitReader(res.Body, maxDrainBytes)) size = drained } c.warn("voice tts rejected", "status", res.StatusCode, "content_type", res.Header.Get("Content-Type"), "response_bytes", size) return fmt.Errorf("%w: status %d", ErrUpstream, res.StatusCode) } // readAudio 读回字节并**按内容裁决**:认不出音频 ⇒ ErrNotAudio,超限 ⇒ ErrTooLarge。 func (c *Client) readAudio(res *http.Response, text string) (Audio, error) { raw, err := io.ReadAll(io.LimitReader(res.Body, c.maxLen+1)) if err != nil { c.warn("voice tts read body", "err", err) return Audio{}, fmt.Errorf("%w: read body: %w", ErrUnreachable, err) } if int64(len(raw)) > c.maxLen { c.warn("voice tts response too large", "limit", c.maxLen) return Audio{}, fmt.Errorf("%w: over %d bytes", ErrTooLarge, c.maxLen) } format, ok := sniffAudio(raw) if !ok { ctype := res.Header.Get("Content-Type") c.warn("voice tts response is not audio", "content_type", ctype, "bytes", len(raw), "text_len", len(text)) return Audio{}, fmt.Errorf("%w: content-type %q, %d bytes", ErrNotAudio, ctype, len(raw)) } return Audio{Format: format, Data: raw}, nil } // warn 记一条 WARN(日志器未注入时用默认日志器,绝不因日志缺失而 panic)。 func (c *Client) warn(msg string, args ...any) { if c.log == nil { return } c.log.Warn(msg, args...) } // Audio 是一段合成音频及其**嗅探所得**格式。它与 // `internal/runtime/multimodal/voice.Audio` 字段同形,由 `Service` 一处映射 // —— 本包因此不依赖那条链的类型,谁都构不成环。 type Audio struct { // Format 是字节嗅探所得的编码名(wav/mp3/ogg/flac/m4a/webm/amr)。 Format string // Data 是原始音频字节。 Data []byte } // redactURL 把 baseURL 收敛成**可打日志**的形态:只保留 scheme 与 host, // 丢掉 userinfo/query(若运维在地址里塞了凭据,别把它写进日志)。 // // 解析不出 host 时**不能**原样返回,两条兜底各挡一类泄漏: // - 含 `@`(userinfo 的分隔符,出现在地址里只可能是凭据)⇒ 整体打码。 // `http://user:pass@` 这种不合法地址因此不会把凭据整个写进日志。 // - 其余多半是**漏了 scheme** 的地址(`127.0.0.1:9880/v1?token=…`:`url.Parse` // 在首段的 `:` 上报错,host 为空)。漏 scheme 不是"原样返回"的理由 —— // 签名与 token 就在 query 里,因此按 `?`/`#` 切掉查询串与片段再返回。 func redactURL(raw string) string { u, err := url.Parse(raw) if err == nil && u.Host != "" { return u.Scheme + "://" + u.Host } if strings.Contains(raw, "@") { return "" } return stripURLQuery(raw) } // stripURLQuery 切掉 raw 里 `?` 或 `#` 起的部分。它是 redactURL 在「解析不出 // host」那条路径上的兜底:那里拿不到 url.URL 的 RawQuery/Fragment,只能按 // 分隔符切。没有分隔符时原样返回(不重新编码、不规范化,便于肉眼比对)。 func stripURLQuery(raw string) string { if i := strings.IndexAny(raw, "?#"); i >= 0 { return raw[:i] } return raw }