Files
Cyrene_v2/internal/runtime/voice/tts/selection.go
T
AskaEth efc25c6a7f feat(voice): emotionTTS 发音人绑定会话人格(不再全局定死一个 character)
主人 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。
2026-10-26 12:57:35 +08:00

108 lines
4.9 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。空串是合法的(= 解析不到),
// 与"没设置"同义 —— 两者都走 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 ""
}