Files
Cyrene_v2/internal/runtime/voice/tts/config.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

140 lines
7.0 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 服务**的运行时合成适配(切片 M35)。
//
// 它与父包 `internal/runtime/voice` 的分工(**别混**):父包管「一条入站语音的
// 来源事实 + 可播副本」(design/24 §10);本包只回答一件事 ——「把这段文字交给
// emotionTTS 换成音频」。它与 `internal/runtime/multimodal/voice` 里那条 STT/TTS
// 降级链**不是**两套 HTTP:本包的 `Client` 满足那条链的 `TTS` 形状,`Service`
// 则把 emotionTTS 摆在那条链**前面**(失败/未启用回落既有链)。
//
// # 为什么它比我方参数"更懂情绪"
//
// 该服务(v5.1.0)的 `POST /v1/audio/speech` **自己**做情绪:先让 LLM 分析待念
// 文本的情绪、在角色素材库里挑一条情绪相符的参考音,再把情绪向量以
// `[EMO:{vector}|{alpha}]base64:...` 的形式塞进 `voice` 字段交给 indexTTS2
// (`core/routers/tts_router.py` 的 `openai_tts_api` + `core/utils/llm_provider.py`
// 的 `synthesize_audio`)。**因此我方没有、也不需要情绪参数** —— 语气由要念的
// 文本自己带(她调措辞就是调语气)。
//
// # 为什么没有 speed 旋钮
//
// 🔴 同一个 `synthesize_audio` 把下游载荷的 `speed` **硬编码成 1.0**(同一文件
// 的 `data_json`),所以 OpenAI 兼容口的 `speed` 收下即丢。**给它做一个旋钮等于
// 做一个假开关** —— 因此 `Config` 刻意不含 `speed`。同理不含 `emotion`/`tone`:
// 那两样上游自己推,我方传什么都是噪音。
//
// # 对外只有两样
//
// 对调用方只暴露 `text`(必填)与角色(由会话 persona 映射而来)。**没有 `format`**:
// 上游只拿它改自报的 media_type,产物恒 `.wav` 无转码,我方只按字节嗅探裁决 ⇒
// 那个键不改变任何交付字节(假开关)。其余全是面板文件里的运维旋钮或代码默认值。
package tts
import (
"strings"
"time"
)
const (
// DefaultBaseURL 是该服务的默认地址(**只绑回环**、无鉴权:绝不是公网地址)。
DefaultBaseURL = "http://127.0.0.1:9880"
// DefaultCharacter 只是**示例/文档用**的角色 id(主人指定的「Cyrene」,
// `GET /api/characters` 实测 id = `char_84fb88a3`)。
//
// 🔴 它**不是**代码里的兜底发音人:把任何角色 id 写死在代码里,正是主人
// 2026-10-26 点出的病害(换了人格仍用老角色的声音)。发音人一律由
// `Config.Personas` / `Config.Default` 显式给出(见 Resolve)。
DefaultCharacter = "char_84fb88a3"
// DefaultTimeout 是单次合成超时。实测一次中文合成约 4~8 秒(它内部还要先跑一次
// LLM 情绪分析 + 素材匹配),故默认留足余量;超时即失败并按既有口径回落。
DefaultTimeout = 60 * time.Second
// defaultMaxAudioBytes 限制读回的音频体积,使异常/畸形响应无法耗尽内存
// (32 MiB 远超一句话的语音,实测 5 秒语音约 100~200 KB)。
defaultMaxAudioBytes = 32 << 20
// maxDrainBytes 是错误响应体的**有界**排空上限:只用来得出一个字节数下界,
// 读到的东西直接丢弃、绝不进日志(见 upstreamStatus 的隐私红线)。
maxDrainBytes = 4096
// speechPath 是 OpenAI 兼容合成端点(已实测可用:200 / RIFF-WAVE)。
speechPath = "/v1/audio/speech"
// modelName 是上游要求的固定模型名(该服务只认 `emotionTTS`)。
modelName = "emotionTTS"
// requestFormat 是请求体里 `response_format` 的**固定值**。
//
// 🔴 它刻意**不可配置**:上游只拿它改 `FileResponse` 的 media_type,产物恒为
// `.wav` 且**无转码**(`tts_router.py:437-438`),我方又从不读 `Content-Type`
// (格式一律由 `sniffAudio` 按字节裁决)⇒ `wav`/`mp3`/`ogg` 交付出去的字节与
// 响应头**完全相同**。做成配置键就是一个假开关(主人对假开关零容忍)。
requestFormat = "wav"
)
// Config 是 emotionTTS 客户端的运行期旋钮(由面板文件
// `$DATA_DIR/config/voice-tts.json` 映射而来;零新 `.env` 键)。
//
// 🔴 刻意**没有** speed / emotion / tone 三项,理由见包注释(上游硬编码或自推)。
// 🔴 刻意**没有** format:上游只拿 `response_format` 改它自报的 media_type,产物
// 恒为 `.wav` 无转码,我方又只按字节嗅探裁决 ⇒ 那个键不改变任何交付字节,是假开关。
// 🔴 也**没有全局 character**:发音人只能由 `Personas`(按会话角色映射)或
// `Default`(仅服务"解析不到 personaId")给出 —— 主人 2026-10-26 的硬要求
// 「TTS 的人物选择要绑定后端的角色,不然换了人格还是用老角色的声音」。
type Config struct {
// Enabled 是总开关。false = 该级整体不参与(调用方应保留既有链,
// 而不是把"没启用"当成"合成失败")。
Enabled bool
// BaseURL 是服务地址;空 = DefaultBaseURL。约定只填回环地址。
BaseURL string
// Personas 是 **personaId → 上游 characterId** 的唯一映射源(也接受
// `char_` 前缀或不带前缀的写法,见 Resolve)。它有键但值为空 = 该角色
// 没有可用发音人 ⇒ 不合成(**绝不**借用别的角色的声音)。
Personas map[string]string
// Default 是**仅当 personaId 解析不到**(空串:会话无绑定 / 读取失败 /
// 该出口本身没有会话)时使用的发音人。它**不**服务"配了 Personas 但没有
// 这个 persona 的键"那种情况 —— 那是 Resolve 的拒绝分支(主人点名的病害)。
Default string
// Timeout 是单次合成超时;<=0 = DefaultTimeout。
Timeout time.Duration
}
// Normalized 返回填好默认值、去掉首尾空白的配置。
//
// 它**不**给 `Personas`/`Default` 补任何默认角色:发音人只有两种合法来源
// (显式映射 / 显式 default),代码里的兜底值就是主人担心的病害本身。它也不接受
// logger:当前没有任何"需要告警的回落"(格式已不是配置项,见 requestFormat)。
func (c Config) Normalized() Config {
out := c
out.BaseURL = strings.TrimRight(strings.TrimSpace(out.BaseURL), "/")
if out.BaseURL == "" {
out.BaseURL = DefaultBaseURL
}
out.Personas = normalizePersonas(out.Personas)
out.Default = strings.TrimSpace(out.Default)
if out.Timeout <= 0 {
out.Timeout = DefaultTimeout
}
return out
}
// normalizePersonas 去掉映射两端的空白与空键(空键不可能是合法 personaId),
// 并保留"键在、值为空"这一形态 —— 它表示"这个角色明确没有发音人",与"没这个键"
// 在 Resolve 里是**不同**的分支(前者拒绝,后者也拒绝;两者都绝不借用别的角色)。
func normalizePersonas(in map[string]string) map[string]string {
if len(in) == 0 {
return nil
}
out := make(map[string]string, len(in))
for persona, character := range in {
key := strings.TrimSpace(persona)
if key == "" {
continue
}
out[key] = strings.TrimSpace(character)
}
return out
}