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:
1 parent
3a2711a7e0
commit
efc25c6a7f
16 files changed
+1210
-503
No files matched your search
@@ -15,6 +15,10 @@ package tts
|
||||
// 🔴 **文本优先**:本层**永不**把 TTS 的失败上抛成"这条回复失败"。失败只做两件事:
|
||||
// 记一条带来源的 WARN + 计数(`Client.Stats`),然后回落既有链;既有链再失败才算
|
||||
// 整体失败,而那时聊天正文早已发出/落库(TTS 是呈现层的增强,不是回复的前置条件)。
|
||||
//
|
||||
// 🔴 **发音人按当次请求的 persona 现算**(主人 2026-10-26 的硬要求):每次
|
||||
// `TTSWithProvider` 都从 ctx 取 personaId、经 `Config.Resolve` 得出 character,
|
||||
// **没有任何跨请求缓存**。因此"换人格后用老角色的声音"不可能发生。
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -79,14 +83,6 @@ func NewService(primary *Client, base Chain, opts ...ServiceOption) *Service {
|
||||
// PrimaryActive 报告 emotionTTS 这一级是否真的在生效(面板据此如实显示"已接")。
|
||||
func (s *Service) PrimaryActive() bool { return s != nil && s.primary != nil && s.primary.Enabled() }
|
||||
|
||||
// Character 返回生效角色(面板展示用)。
|
||||
func (s *Service) Character() string {
|
||||
if s == nil || s.primary == nil {
|
||||
return ""
|
||||
}
|
||||
return s.primary.Character()
|
||||
}
|
||||
|
||||
// Stats 返回 emotionTTS 这一级的计数(尝试/失败);未接线时为 0 值。
|
||||
func (s *Service) Stats() Stats {
|
||||
if s == nil || s.primary == nil {
|
||||
@@ -125,22 +121,35 @@ func (s *Service) TTS(ctx context.Context, text string) (mvoice.Audio, error) {
|
||||
return audio, err
|
||||
}
|
||||
|
||||
// TTSWithProvider 先试 emotionTTS,失败/未启用则回落既有链,并如实报告实际
|
||||
// 服务的那一级(`emotiontts` 或既有链的 cloud/local/...)。
|
||||
// TTSWithProvider 按**本次请求的角色**选发音人并合成;失败/未启用则回落既有链,
|
||||
// 并如实报告实际服务的那一级(`emotiontts` 或既有链的 cloud/local/...)。
|
||||
//
|
||||
// 三条纪律:
|
||||
// - **失败绝不上抛成回复失败**:先回落,回落也失败才把后者的错误交给调用方
|
||||
// (既有行为逐字不变);
|
||||
// - **WARN 带来源**:哪一级、什么原因,一条说清(正文绝不进日志,只有长度);
|
||||
// - **未启用不是故障**:只记 DEBUG,不刷 WARN。
|
||||
// 分支(与 `Config.Resolve` 一一对应,验收清单见 selection.go):
|
||||
// - 选到发音人 ⇒ 用它合成;上游失败 ⇒ 回落既有链 + WARN(口径不变);
|
||||
// - **选不到发音人**(persona 无映射 / 解析不到且没配 default)⇒ **不合成、
|
||||
// 也不回落既有链**,直接回一个可分类错误 + 一条 WARN。回落会让她用**另一个
|
||||
// 声音**说话,正是主人点名的病害;文本早已发出,因此这里"没有声音"是安全结局。
|
||||
func (s *Service) TTSWithProvider(ctx context.Context, text string) (mvoice.Audio, string, error) {
|
||||
if s.PrimaryActive() {
|
||||
audio, err := s.primary.Speech(ctx, text)
|
||||
if err == nil {
|
||||
return mvoice.Audio{Format: audio.Format, Data: audio.Data}, ProviderName, nil
|
||||
}
|
||||
s.degrade(ctx, text, err)
|
||||
if !s.PrimaryActive() {
|
||||
return s.fallback(ctx, text)
|
||||
}
|
||||
selection, err := s.primary.Config().Resolve(PersonaIDFromContext(ctx))
|
||||
if err != nil {
|
||||
// 🔴 绝不借用别的角色的声音:不合成、不回落,如实说清原因。
|
||||
s.log.Warn("voice tts skipped: no character for this persona",
|
||||
"provider", ProviderName, "persona", PersonaIDFromContext(ctx), "text_len", len(text), "err", err)
|
||||
return mvoice.Audio{}, "", err
|
||||
}
|
||||
audio, err := s.primary.Speech(ctx, selection.Character, text)
|
||||
if err == nil {
|
||||
return mvoice.Audio{Format: audio.Format, Data: audio.Data}, ProviderName, nil
|
||||
}
|
||||
s.degrade(ctx, selection, text, err)
|
||||
return s.fallback(ctx, text)
|
||||
}
|
||||
|
||||
// fallback 走既有链;没有底链时回一个明确的错误(不 panic)。
|
||||
func (s *Service) fallback(ctx context.Context, text string) (mvoice.Audio, string, error) {
|
||||
if s.base == nil {
|
||||
return mvoice.Audio{}, "", mvoice.ErrProviderFailed
|
||||
}
|
||||
@@ -149,7 +158,7 @@ func (s *Service) TTSWithProvider(ctx context.Context, text string) (mvoice.Audi
|
||||
|
||||
// degrade 记录一次 emotionTTS 降级:未启用是 DEBUG(正常状态),其余是 WARN
|
||||
// (异常状态,但已被回落吸收)。日志只带文本长度,**绝不带正文**。
|
||||
func (s *Service) degrade(ctx context.Context, text string, err error) {
|
||||
func (s *Service) degrade(ctx context.Context, sel Selection, text string, err error) {
|
||||
if ctx.Err() != nil {
|
||||
// 回合被取消/超时的情形已由 Client 记过(unreachable),这里只补一句
|
||||
// 「因此这次没声音」,避免同一条失败刷两条 WARN。
|
||||
@@ -157,5 +166,7 @@ func (s *Service) degrade(ctx context.Context, text string, err error) {
|
||||
return
|
||||
}
|
||||
s.log.Warn("voice tts degraded to fallback chain",
|
||||
"provider", ProviderName, "text_len", len(text), "err", err)
|
||||
"provider", ProviderName, "persona", sel.PersonaID,
|
||||
"character", sel.Character, "source", string(sel.Source),
|
||||
"text_len", len(text), "err", err)
|
||||
}
|
||||
Reference in new issue
Block a user