Files
AskaEth b90811fae4 docs(voice): 收口「没会话/没绑定就走 default」的镜像注释(r4)
唯一 finding:audio.go 的 speechRequest.SessionID 注释原本写「省略它时本口没有会话
概念 ⇒ 走 emotionTTS 的 default 分支」——**反了**。真实链路是
effectivePersona(ctx,"") → sessionPersona("") 为空**不是终点** → Effective =
defaultPersonaID() → 常规安装必有全局默认人格 ⇒ personaId 非空 ⇒ 走 personas 映射。
照那句话改的人会写出 `if req.SessionID == "" { personaID = "" }`,正是 r2 修掉的病。
改为:省略时按**全局默认人格**选发音人(会话绑定 → 全局默认人格 → 两者都没有才落到
default 字符),并显式写了"不要写成 if SessionID == \"\" { personaID = \"\" }"。

顺手同根三处(不留第四轮):
- tts/config.go 的 Config.Default 注释:成因表「会话无绑定 / 读取失败 / 该出口本身
  没有会话」已过时 ⇒ 改为「空串的唯一合法成因是连全局默认人格都没有」,并写明不是
  "会话未绑定"、也不是"出站口没有会话"。
- runtimeconfig/voicetts.go 的 Default 字段注释与文件头同族措辞同步;
- selection.go 的 WithPersonaID / Resolve 分支表、service.go 的分支小标把
  「解析不到」统一改成「连全局默认人格都没有」;
- voice_emotiontts_test.go 的 TestSpeechEndpointNoSessionUsesDefault 注释同步(该用例
  没接人格库,所以确实落到 default —— 但成因写对了);
- wire_voice_tts_test.go nit:改成「已废弃的全局 `character` 不生效」(墓碑键仍在)。

纯注释:`git diff` 里非注释改动行为空。go build/vet 通过;四包 go test -count=1 全绿。
2026-10-26 13:40:00 +08:00

145 lines
7.4 KiB
Go
Raw Permalink 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 传空串)时使用的发音人。
//
// 🔴 空串的**唯一合法成因**是"连全局默认人格都没有"(人格库未接线 / 没有任何角色
// 被标为默认)。**不是**"会话未绑定"、也不是"这个出站口没有会话":那些情况下
// 调用方给的是**生效人格**(会话绑定 → 全局默认人格),非空。若把"没绑定"当成
// 空传进来,`default` 就会顶掉全局默认人格的声音 —— 主人点名的病害(复审 r2/r4)。
//
// 它**不**服务"配了 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
}