redactURL's fallback returned the raw string when url.Parse found no host. A scheme-less BaseURL (127.0.0.1:9880/v1?token=...) fails to parse, so the signed query reached the WARN log and the error chain verbatim. Strip at ? and # in that path, keeping the '@' full-redaction rule first. Test pins the scheme-less query/fragment cases and goes red if the fix is reverted.
335 lines
14 KiB
Go
335 lines
14 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"
|
||
|
||
"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 "<redacted>"
|
||
}
|
||
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
|
||
}
|