上一版把 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。
140 lines
7.0 KiB
Go
140 lines
7.0 KiB
Go
// 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
|
||
}
|
||
|