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 全绿。
This commit is contained in:
AskaEth committed 2026-10-26 13:40:00 +08:00
1 parent 1c2944fcf4
commit b90811fae4
8 files changed
+41 -22

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ package main
//
// 1. 面板文件缺失 ⇒ **这一级不启用**,返回的仍是既有链本身(行为逐字不变);
// 2. 文件里 `enabled:true` ⇒ 返回 emotionTTS 门面,`personas`/`default`/`timeoutSec`
// 按文件来(没有"角色"或"格式"这种全局键:发音人只能来自 persona 映射);
// 按文件来(发音人只能来自 persona 映射;已废弃的全局 `character` 不生效);
// 3. 文件坏掉 ⇒ **不启用**(宁可没有这一级,也不拿读不懂的配置去驱动外部服务)。
//
// 它用 `DefaultVoiceTTSPath` 的真实落点($DATA_DIR/config/voice-tts.json)跑,
+9 -4
View File
@@ -10,7 +10,8 @@
// 🔴 **发音人必须绑定人格**(主人 2026-10-26 逐字:「TTS 的人物选择是要绑定后端的
// 角色的,不然会出现以后换了别的角色(人格配置),还是用老角色的 TTS 角色」):
// 因此本文件的核心是 `personas`(personaId → characterId)与 `default`(**仅**
// 服务"解析不到 personaId")。全局 `character` 键已废弃并被忽略(见字段注释)。
// 服务"连全局默认人格都没有"这一种情形 —— 不是"会话未绑定")。
// 全局 `character` 键已废弃并被忽略(见字段注释)。
// 映射的**唯一真源**就是本文件 —— `configs/personas/*.yaml` 不参与。
//
// 🔴 **刻意没有 speed / emotion / tone 三键**(塔 2026-10-26 裁决,实测证据):
@@ -81,10 +82,14 @@ type VoiceTTSTuning struct {
// - 有键但值为空串 = 该角色明确没有发音人 ⇒ 不合成(绝不借用别的角色)。
// - **没有这个键** ⇒ 同样不合成,且**不**回落到下面的 `default`。
Personas map[string]string `json:"personas,omitempty"`
// Default 是**仅当 personaId 解析不到**(会话无绑定 / 读取失败 / 该出站口本身
// 没有会话)时使用的发音人;空 = 没配 ⇒ 那种情况**不合成**。
// Default 是**仅当连生效人格都没有**(personaId 传空串)时使用的发音人;
// 空 = 没配 ⇒ 那种情况**不合成**。
//
// 🔴 它**不服务**「配了 personas 但没有这个 persona 的键」——那种情况一律不合成
// 🔴 空串的唯一合法成因是"连全局默认人格都没有"(人格库未接线 / 没有任何角色被
// 标为默认)—— **不是**"会话未绑定",也不是"这个出站口没有会话":那些情况下
// 调用方传的是**生效人格**(会话绑定 → 全局默认人格),非空。
//
// 它**不服务**「配了 personas 但没有这个 persona 的键」——那种情况一律不合成
// (见 `tts.Config.Resolve` 的拒绝分支)。用 default 去顶正是"换了人格还用老角色
// 声音"的病害本身。
Default string `json:"default,omitempty"`
+9 -4
View File
@@ -92,9 +92,15 @@ type Config struct {
// `char_` 前缀或不带前缀的写法,见 Resolve)。它有键但值为空 = 该角色
// 没有可用发音人 ⇒ 不合成(**绝不**借用别的角色的声音)。
Personas map[string]string
// Default 是**仅当 personaId 解析不到**(空串:会话无绑定 / 读取失败 /
// 该出口本身没有会话)时使用的发音人。它**不**服务"配了 Personas 但没有
// 这个 persona 的键"那种情况 —— 那是 Resolve 的拒绝分支(主人点名的病害)。
// Default 是**仅当连生效人格都没有**(personaId 传空串)时使用的发音人。
//
// 🔴 空串的**唯一合法成因**是"连全局默认人格都没有"(人格库未接线 / 没有任何角色
// 被标为默认)。**不是**"会话未绑定"、也不是"这个出站口没有会话":那些情况下
// 调用方给的是**生效人格**(会话绑定 → 全局默认人格),非空。若把"没绑定"当成
// 空传进来,`default` 就会顶掉全局默认人格的声音 —— 主人点名的病害(复审 r2/r4)。
//
// 它**不**服务"配了 Personas 但没有这个 persona 的键"那种情况 —— 那是 Resolve
// 的拒绝分支。
Default string
// Timeout 是单次合成超时;<=0 = DefaultTimeout。
Timeout time.Duration
@@ -136,4 +142,3 @@ func normalizePersonas(in map[string]string) map[string]string {
}
return out
}
+7 -6
View File
@@ -61,10 +61,10 @@ var (
//
// 四条分支(与主人的验收清单逐条对应):
//
// personaID 有映射 ⇒ (Selection{mapped}, nil)
// personaID 为空/解析不到 + 配了 Default ⇒ (Selection{default}, nil)
// personaID 为空/解析不到 + 没配 Default ⇒ ("", ErrNoCharacter)
// personaID 非空 + 没有映射(含映射值为空) ⇒ ("", ErrPersonaUnmapped)
// personaID 有映射 ⇒ (Selection{mapped}, nil)
// personaID 为空(连全局默认人格都没有)+ 配了 Default ⇒ (Selection{default}, nil)
// personaID 为空 + 没配 Default ⇒ ("", ErrNoCharacter)
// personaID 非空 + 没有映射(含映射值为空) ⇒ ("", ErrPersonaUnmapped)
//
// 🔴 第四行**不会**看 Default —— 这正是"不许借用另一个角色"的编码位置。
func (c Config) Resolve(personaID string) (Selection, error) {
@@ -85,8 +85,9 @@ func (c Config) Resolve(personaID string) (Selection, error) {
// personaKey 是 ctx 里携带 personaId 的键(每次请求现传,绝不缓存成全局)。
type personaKey struct{}
// WithPersonaID 返回携带本次请求所属角色 id 的 ctx。空串是合法的(= 解析不到),
// 与"没设置"同义 —— 两者都走 default 分支。
// WithPersonaID 返回携带本次请求**生效人格** id 的 ctx。空串是合法的,但它的唯一
// 合法成因是"连全局默认人格都没有"——**不是**"会话未绑定"(未绑定但存在全局默认人格
// 时,调用方应传全局默认人格)。传空串只在 Resolve 层意味着"走 default 分支"。
//
// 调用方是**知道会话**的那一层(网关的两条出站口):它先按当次请求解析出
// `chat_sessions.persona_id`,再交给语音面。语音面自己不去查库,因此这里没有
+1 -1
View File
@@ -134,7 +134,7 @@ func (s *Service) TTS(ctx context.Context, text string) (mvoice.Audio, error) {
//
// 分支(与 `Config.Resolve` 一一对应,验收清单见 selection.go):
// - 选到发音人 ⇒ 用它合成;上游失败 ⇒ 回落既有链 + WARN(口径不变);
// - **选不到发音人**(persona 无映射 / 解析不到且没配 default)⇒ **不合成、
// - **选不到发音人**(persona 无映射 / 连生效人格都没有且没配 default)⇒ **不合成、
// 也不回落既有链**,直接回一个可分类错误 + 一条 WARN。回落会让她用**另一个
// 声音**说话,正是主人点名的病害;文本早已发出,因此这里"没有声音"是安全结局。
func (s *Service) TTSWithProvider(ctx context.Context, text string) (mvoice.Audio, string, error) {
+1 -1
View File
@@ -9,7 +9,7 @@ package tts
// 2. 🔴 同一进程先后用两个 persona,第二次绝不沿用第一次的 voice(回归锁,
// 直接对着主人担心的事);
// 3. persona 无映射且没配 default ⇒ **不合成**,且**上游请求数为 0**;
// 4. persona 解析不到且配了 default ⇒ 用 default;
// 4. 连生效人格都没有(传空串)且配了 default ⇒ 用 default;
// 5. 有映射但上游拒绝 ⇒ 沿用既有降级(回落既有链,口径不变);
// 6. 未启用 ⇒ 完全透传既有链;STT/克隆永远走既有链。
+11 -4
View File
@@ -30,10 +30,17 @@ type transcribeRequest struct {
// 每请求情绪/语气覆盖,用于克隆音色(声音复刻);系统音色
// (longxiaochun_v3)忽略它,绝不接收自由形式的指令。
//
// SessionID 是**可选**的会话标识(切片 M35,纯增量):发音人按
// 「该会话绑定的人格」解析(`chat_sessions.persona_id`)。省略它时本口没有会话
// 概念 ⇒ 走 emotionTTS 的 default 分支(没配 default 就不合成)。客户端**绝不**
// 通过它选音色:音色由服务端的 persona 映射决定,请求只声明"这是哪个会话"。
// SessionID 是**可选**的会话标识(切片 M35,纯增量):发音人按该会话的**生效
// 人格**解析 —— 会话绑定 → **全局默认人格** → 两者都没有才是空。
//
// 🔴 省略它**不等于**"没有角色":本口此时按**全局默认人格**选发音人(常规安装必
// 有全局默认人格 ⇒ 仍走 `personas` 映射);只有连全局默认人格都没有时,才落到
// `voice-tts.json` 的 `default` 字符(没配则不合成)。因此**不要**写成
// `if req.SessionID == "" { personaID = "" }` —— 那会让本口永远用 `default` 那个
// 声音,正是主人在意的「换人格还用老角色」(复审 r2/r4)。
//
// 客户端**绝不**通过它选音色:音色由服务端的 persona 映射决定,请求只声明
// "这是哪个会话"。
type speechRequest struct {
Text string `json:"text"`
Voice string `json:"voice,omitempty"`
@@ -168,7 +168,8 @@ func TestSpeechEndpointUnmappedPersonaRefuses(t *testing.T) {
}
// TestSpeechEndpointNoSessionUsesDefault 钉住"这条口没有会话时"的诚实处置:
// 省略 sessionId ⇒ persona 解析不到 ⇒ 用配置的 default(没配则同样不合成)。
// 省略 sessionId ⇒ 生效人格只能来自**全局默认人格**;本用例没有接人格库,
// 因此连默认人格都没有 ⇒ 才落到配置的 default(没配则同样不合成)。
func TestSpeechEndpointNoSessionUsesDefault(t *testing.T) {
env := newEnv(t, nil, false)
rec := &vocalRecorder{}