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

109 lines
5.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
// 本文件是**发音人的选角**:把「本次请求属于哪个角色(personaId)」映射成
// 「上游要用的 characterId」。它是主人 2026-10-26 那条硬要求的落点:
//
// 「TTS 的人物选择是要绑定后端的角色的,不然会出现以后换了别的角色(人格配置),
// 还是用老角色的 TTS 角色」
//
// 三条设计约束(违反其一就是那条病害复发):
//
// 1. **单一真源**:映射只来自 `Config.Personas`(面板文件 voice-tts.json)。
// `configs/personas/*.yaml` **不参与** —— 那是启动期静态配置,面板是运行期
// 可热调,两处必然漂移。
// 2. **不许定死**:代码里没有任何兜底 character(`DefaultCharacter` 只是文档示例)。
// 3. 🔴 **映射缺失时绝不借用别人的声音**:`Default` 只服务「personaId 解析不到」
// (空串),**不服务**「配了 Personas 但没有这个 persona 的键」——后者一律
// 拒绝合成,让文本照发。用 default 去顶就是"换了人格还用老角色的声音"。
//
// 选角是**每次调用现算**的纯函数(读 ctx 里的 personaId + 配置),没有任何跨请求
// 缓存 —— 因此同一进程内先后服务两个 persona,第二次绝不会沿用第一次的 voice
//(`selection_test.go` 的 `TestResolveIsNotCachedAcrossPersonas` 钉这一条)。
import (
"context"
"errors"
"strings"
)
// Selection 是本次合成选中的发音人与它的**来源**(来源要进日志:出问题时能一眼
// 看出"这次是按哪条规则选的")。
type Selection struct {
// PersonaID 是本次请求所属角色(空 = 解析不到)。
PersonaID string
// Character 是映射到的上游角色 id(可能带 `char_` 前缀,按原样发出)。
Character string
// Source 是选角来源:mapped(persona 映射命中)| default(解析不到 persona,
// 用了配置里的 default)。
Source SelectionSource
}
// SelectionSource 是选角来源的闭集。
type SelectionSource string
const (
// SourceMapped 表示命中 `Config.Personas` 里该 persona 的映射。
SourceMapped SelectionSource = "mapped"
// SourceDefault 表示 personaId 解析不到,用了 `Config.Default`。
SourceDefault SelectionSource = "default"
)
// 选角失败的原因(可分类 —— 面板/日志据此说清"为什么这次没声音")。
var (
// ErrNoCharacter 报告「解析不到 personaId,且没有配 default」⇒ 不合成。
ErrNoCharacter = errors.New("voice/tts: no character for this request")
// ErrPersonaUnmapped 报告「这个 persona 没有映射」。它**绝不**回落到 default:
// 那会让换了人格的她继续用别的角色的声音(主人点名的病害)。
ErrPersonaUnmapped = errors.New("voice/tts: persona has no character mapping")
)
// Resolve 按 personaID 选出本次要用的 character。
//
// 四条分支(与主人的验收清单逐条对应):
//
// personaID 有映射 ⇒ (Selection{mapped}, nil)
// personaID 为空(连全局默认人格都没有)+ 配了 Default ⇒ (Selection{default}, nil)
// personaID 为空 + 没配 Default ⇒ ("", ErrNoCharacter)
// personaID 非空 + 没有映射(含映射值为空) ⇒ ("", ErrPersonaUnmapped)
//
// 🔴 第四行**不会**看 Default —— 这正是"不许借用另一个角色"的编码位置。
func (c Config) Resolve(personaID string) (Selection, error) {
trimmed := strings.TrimSpace(personaID)
if trimmed == "" {
if c.Default == "" {
return Selection{}, ErrNoCharacter
}
return Selection{Character: c.Default, Source: SourceDefault}, nil
}
character := strings.TrimSpace(c.Personas[trimmed])
if character == "" {
return Selection{PersonaID: trimmed}, ErrPersonaUnmapped
}
return Selection{PersonaID: trimmed, Character: character, Source: SourceMapped}, nil
}
// personaKey 是 ctx 里携带 personaId 的键(每次请求现传,绝不缓存成全局)。
type personaKey struct{}
// WithPersonaID 返回携带本次请求**生效人格** id 的 ctx。空串是合法的,但它的唯一
// 合法成因是"连全局默认人格都没有"——**不是**"会话未绑定"(未绑定但存在全局默认人格
// 时,调用方应传全局默认人格)。传空串只在 Resolve 层意味着"走 default 分支"。
//
// 调用方是**知道会话**的那一层(网关的两条出站口):它先按当次请求解析出
// `chat_sessions.persona_id`,再交给语音面。语音面自己不去查库,因此这里没有
// 第二个"会话 → 角色"的解析器(design/22 §4.5:一个解析器)。
func WithPersonaID(ctx context.Context, personaID string) context.Context {
return context.WithValue(ctx, personaKey{}, strings.TrimSpace(personaID))
}
// PersonaIDFromContext 返回 ctx 里携带的角色 id(未设置时为 "")。
func PersonaIDFromContext(ctx context.Context) string {
if ctx == nil {
return ""
}
if v, ok := ctx.Value(personaKey{}).(string); ok {
return v
}
return ""
}