Files
Cyrene_v2/internal/runtime/voice/tts/client.go
T
AskaEth 8820b0b316 fix(voice): 修 r1 复审四条(正文进日志的隐私泄漏 / format 假开关 / Stats 无消费者 / redactURL 兜底)
上一版把 r1 的三条 finding 静默漏了(我在交付前没读 inbox),本提交逐条补齐:

1. 🔴 隐私:upstreamStatus 不再读也不记响应体
   FastAPI 的 422 会把请求体里的 input 整段回显,原来「错误体前 512 字节进 WARN」
   等于把她的原话写进日志。现在只记 status / content_type / response_bytes
   (字节数优先取 Content-Length,缺失时有界排空取上界,读到即丢)。
   新用例 TestSpeechRejectLogOmitsRequestText 用**回显正文**的假上游抓它;
   变异(把 body 塞回 WARN)实测打红。

2. format 键彻底删除(假开关)
   上游只拿 response_format 改自报 media_type,产物恒 .wav 无转码,我方只按字节
   嗅探裁决 ⇒ wav/mp3/ogg 交付字节与响应头完全相同。删 tts.Config.Format 与
   VoiceTTSTuning.Format,encodeSpeech 硬编码 requestFormat="wav"(不可配置)。
   用例钉住请求体恒为 wav;变异(改成 mp3)打红。旧文件里残留的 format 键被忽略。

3. Stats 接进 degrade 的 WARN(attempts/failures)
   否则统计在生产日志里没有消费者。TestServiceDegradeLogsCounters 钉住;
   变异(去掉两个字段)打红。

4. redactURL 兜底含 "@" 整体打码
   解析失败/无 host 时原来原样返回,user:pass@ 会漏凭据。TestRedactURLCredentials
   覆盖合法 URL 去 userinfo、非法含 @ 打码;变异(退回原样返回)打红。

另:关闭态零行为变化的**当前 tip 重跑**——
- handleSpeech 的 persona 解析改为只在语音服务实现 personaVoice 且 PrimaryActive
  时才查库;未接 emotionTTS(既有链/stub)时零额外 I/O、ctx 不改、音频字节相同。
  用例 TestSpeechEndpointDisabledVoiceDoesNotReadPersona(含对照组证明闸不是恒关);
  变异(去掉闸)打红。
- voice.go 在 dev 基线里本就有 env.PersonaID 解析(无人格解析新增 I/O),本次未改。

Config.Normalized 去掉未使用的 logger 参数。go build/vet/test(含 -race)全绿;
真机 live 复测:cyrene 264104 B、furina 221194 B。
2026-10-26 13:06:39 +08:00

310 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"
)
// 本包的哨兵错误。每个都以 %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", 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。正文**不进日志**。
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"
}
c.warn("voice tts unreachable", "reason", reason, "endpoint", redactURL(endpoint), "err", err)
return fmt.Errorf("%w: %s: %w", ErrUnreachable, reason, err)
}
// 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 时**不能**原样返回:`http://user:pass@` 这种不合法地址会
// 把凭据整个写进日志。因此兜底判据是"含 `@` 就整体打码"(`@` 是 userinfo 的
// 分隔符,出现在地址里只可能是凭据)。
func redactURL(raw string) string {
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
if strings.Contains(raw, "@") {
return "<redacted>"
}
return raw
}
return u.Scheme + "://" + u.Host
}