上一版把 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。
310 lines
13 KiB
Go
310 lines
13 KiB
Go
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
|
||
}
|