主人 2026-10-26 逐字:「TTS 的人物选择是要绑定后端的角色的,不然会出现以后换了
别的角色(人格配置),还是用老角色的 TTS 角色」——上一版把 character 写在面板文件
里当全局值,换人格后声音不变,是真缺陷。
配置形状(单一真源 = $DATA_DIR/config/voice-tts.json):
{"enabled":true,
"personas":{"<personaId>":"<characterId>"},
"default":"<characterId>", // 只服务「解析不到 personaId」
"format":"wav","timeoutSec":60}
- 旧的全局 `character` 键**废弃并被忽略**(启动 WARN 提示改用 personas);
Config.Normalized 不给任何角色兜底(代码里写死默认角色=病害本身);
- configs/personas/*.yaml 不参与(塔裁定:避免启动期静态配置与面板两处漂移);
- 零新 .env 键。
选角四条分支(Config.Resolve,纯函数、无缓存):
① persona 有映射 ⇒ 用它;
② personaId 为空/解析不到 + 配了 default ⇒ 用 default;
③ personaId 为空 + 没配 default ⇒ 不合成(ErrNoCharacter,上游 0 请求);
④ persona 无映射(含映射值为空)⇒ 拒绝(ErrPersonaUnmapped),
**绝不**拿 default 或别的角色顶 —— 主人担心的病害本身。
两条既有出站口各自按当次请求解析 persona(不在 SetVoice 时算一次缓存成全局):
- WS voice_output:用本回合的 env.PersonaID(runVoiceTurn/runVoiceTurnDirect),
经 voiceReplySink 传给 synthesizeVoiceReply,再 WithPersonaID 交给语音面;
- POST /api/v1/audio/speech:请求体新增**可选** sessionId(纯增量),
经 turnEnvResolver.sessionPersona 解析;省略 ⇒ 走 default 分支。
Client.Speech 改为**每次传入 character**(内部不缓存发音人);Provider 适配同样按
ctx 的 personaId 现算。选不到发音人时**不回落既有链**(回落=用另一个声音说话),
上游拒绝(404/500)仍按既有口径回落既有链+WARN。
测试:四条分支各自可变异打红,断言打在「上游请求体里的 voice」与帧/响应字节上;
含「同一进程先后两个 persona,第二次绝不沿用第一次 voice」的回归锁,
以及两条出站口的端到端 persona 切换。四条变异(未映射回落 default / 客户端缓存
voice / REST 不看会话 / 选不到时回落底链)均已实测打红后还原。
go build ./... / go vet ./... / go test -race 全绿;真机 live 证据:
cyrene(char_84fb88a3)=254630 B、furina(char_5314489e)=213392 B。
108 lines
4.9 KiB
Go
108 lines
4.9 KiB
Go
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。空串是合法的(= 解析不到),
|
||
// 与"没设置"同义 —— 两者都走 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 ""
|
||
}
|