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。
This commit is contained in:
AskaEth committed 2026-10-26 12:57:35 +08:00
1 parent 3a2711a7e0
commit efc25c6a7f
16 files changed
+1210 -503

No files matched your search

+15 -9
View File
@@ -123,9 +123,6 @@ func (c *Client) Config() Config { return c.cfg }
// Enabled 报告这一级是否启用。
func (c *Client) Enabled() bool { return c.cfg.Enabled }
// Character 返回生效角色(默认 `char_84fb88a3`)。
func (c *Client) Character() string { return c.cfg.Character }
// RedactedBaseURL 返回**可安全打日志**的服务地址(只保留 scheme 与 host ——
// 运维若在地址里塞了凭据,日志不该把它写出来)。
func (c *Client) RedactedBaseURL() string { return redactURL(c.cfg.BaseURL) }
@@ -135,20 +132,29 @@ func (c *Client) Stats() Stats {
return Stats{Attempts: c.attempts.Load(), Failures: c.failures.Load()}
}
// Speech 把文本合成成音频。这是本包的**唯一网络出口**:`Service`(降级包装)与
// 既有语音链的 `TTS` 形状都走它,绝不存在第二处 HTTP 调用。
// Speech 把文本用**指定发音人**合成成音频。这是本包的**唯一网络出口**:
// `Service`(降级包装)与既有语音链的 `TTS` 形状都走它,绝不存在第二处 HTTP 调用。
//
// `character` 是**每次调用显式传入**的,客户端内部**不缓存**任何发音人 —— 因此
// 同一进程先后服务两个角色,第二次绝不会沿用第一次的 voice(主人 2026-10-26 的
// 硬要求;由 `Service` 按每次请求的 personaId 现算,见 selection.go)。
// 空 character 一律拒绝:宁可没有声音,也不要用一个"默认"角色的声音冒充她。
//
// 返回值里的 `Format` 是**字节嗅探**所得,不是请求里那个 `response_format`。
func (c *Client) Speech(ctx context.Context, text string) (Audio, error) {
func (c *Client) Speech(ctx context.Context, character, text string) (Audio, error) {
if !c.cfg.Enabled {
return Audio{}, ErrDisabled
}
voice := strings.TrimSpace(character)
if voice == "" {
return Audio{}, ErrNoCharacter
}
trimmed := strings.TrimSpace(text)
if trimmed == "" {
return Audio{}, ErrEmptyText
}
c.attempts.Add(1)
audi, err := c.speak(ctx, trimmed)
audi, err := c.speak(ctx, voice, trimmed)
if err != nil {
c.failures.Add(1)
}
@@ -156,8 +162,8 @@ func (c *Client) Speech(ctx context.Context, text string) (Audio, error) {
}
// speak 是 Speech 的主体:编码 → 发送 → 判状态 → 嗅探。失败原因已分类。
func (c *Client) speak(ctx context.Context, text string) (Audio, error) {
body, err := encodeSpeech(text, c.cfg.Character, c.cfg.Format)
func (c *Client) speak(ctx context.Context, character, text string) (Audio, error) {
body, err := encodeSpeech(text, character, c.cfg.Format)
if err != nil {
return Audio{}, err
}