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

+17 -7
View File
@@ -12,6 +12,7 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
voicetts "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/tts"
)
// VoiceService 为 voice_input WS 路由运行语音转文本与
@@ -177,7 +178,7 @@ func (h *hub) runVoiceTurn(c *wsConn, req voiceInputRequest) {
msgs := []model.Message{model.TextMessage(model.RoleUser, transcript)}
// 两条「怎么回到这条连接」的通路都由本连接给出:收尾回调(音频回复,C1)与
// 定向拒绝回调(队列满,G5)。编排器只把它们当成本回合的传输线索。
ctx = withVoiceReplySink(ctx, h.voiceReplySink(c, req.SessionID, req.DeviceID, transcript))
ctx = withVoiceReplySink(ctx, h.voiceReplySink(c, req.SessionID, req.DeviceID, env.PersonaID, transcript))
ctx = withPendingFullNotifier(ctx, c.notifyPendingFull)
if h.orchestrator != nil {
h.orchestrator.Submit(ctx, req.SessionID, msgs, env)
@@ -196,16 +197,20 @@ func (h *hub) runVoiceTurnDirect(ctx context.Context, c *wsConn, sessionID, devi
h.voiceError(c, "voice chat failed")
return
}
h.synthesizeVoiceReply(ctx, c, sessionID, deviceID, transcript, voiceReplyText(res))
h.synthesizeVoiceReply(ctx, c, sessionID, deviceID, env.PersonaID, transcript, voiceReplyText(res))
}
// voiceReplySink 返回本次语音请求的收尾回调:回合结算后取助手正文 → TTS →
// **定向**回发起它的那根连接。它是 C1 的核心接线点 —— 回合异步完成,只有结算处
// 知道助手正文。
//
// personaID 是**本回合**解析出的角色(`env.PersonaID`,会话无绑定时为空)。它随
// 回调一起传下去,使合成按这一回合的角色选发音人 —— 而不是进程启动时定死一个。
// 它不是身份输入(身份仍由 actor 元数据决定),只决定用哪个上游音色。
//
// 连接已断(客户端走了)时记一条带来源的 WARN,绝不静默丢:那正是「静默成功但
// 没声音」那类缺陷的温床(协同 C2 的教训)。
func (h *hub) voiceReplySink(c *wsConn, sessionID, deviceID, transcript string) VoiceReplyFunc {
func (h *hub) voiceReplySink(c *wsConn, sessionID, deviceID, personaID, transcript string) VoiceReplyFunc {
return func(res *loop.Result) {
if c.closed() {
h.voiceLog().Warn("voice reply dropped", "reason", "connection closed", "session", sessionID, "device_id", deviceID)
@@ -213,26 +218,31 @@ func (h *hub) voiceReplySink(c *wsConn, sessionID, deviceID, transcript string)
}
// 回合的 ctx 已随回合消亡,因此合成用全新的后台 context:音频是「回复已
// 落库」之后的增强,不该被一次被取消的回合连带取消。
h.synthesizeVoiceReply(context.Background(), c, sessionID, deviceID, transcript, voiceReplyText(res))
h.synthesizeVoiceReply(context.Background(), c, sessionID, deviceID, personaID, transcript, voiceReplyText(res))
}
}
// synthesizeVoiceReply 把助手正文合成音频并定向回发起这条语音的连接
// (协同 C1/C3)。三条纪律:
// (协同 C1/C3)。四条纪律:
// - TTS 失败或降级为空音频时**照旧回帧**并带既有 provider 降级信号,让客户端
// 能提示「没生成声音」(绝不是只回一条错误帧就完事);
// - 没有助手正文(被取消 / 空回复)时不打一次无谓的 TTS,直接回降级帧 ——
// 客户端总能看到这条语音回合的终点,而不是永远等一个不会来的 voice_output;
// - **发音人按本回合的 personaID 现解析**(`voicetts.WithPersonaID`):换人格后
// 绝不会继续用老角色的声音(主人 2026-10-26 的硬要求);
// - 日志只带会话与设备 id,**绝不记正文/音频**。
func (h *hub) synthesizeVoiceReply(ctx context.Context, c *wsConn, sessionID, deviceID, transcript, reply string) {
func (h *hub) synthesizeVoiceReply(ctx context.Context, c *wsConn, sessionID, deviceID, personaID, transcript, reply string) {
if strings.TrimSpace(reply) == "" {
h.voiceLog().Warn("voice reply empty", "reason", "turn produced no text", "session", sessionID, "device_id", deviceID)
emitVoiceOutput(c, sessionID, deviceID, transcript, "", voice.Audio{}, providerSignal("", true))
return
}
// 把**本回合**的角色交给语音面选发音人;空 personaID = 会话无绑定 ⇒ 语音面
// 走它的 default 分支(没配 default 就不合成,文本已发出)。
ctx = voicetts.WithPersonaID(ctx, personaID)
audi, rung, err := ttsWithProvider(ctx, h.voice, reply)
if err != nil {
h.voiceLog().Warn("voice tts", "err", err, "session", sessionID, "device_id", deviceID)
h.voiceLog().Warn("voice tts", "err", err, "session", sessionID, "device_id", deviceID, "persona", personaID)
emitVoiceOutput(c, sessionID, deviceID, transcript, reply, voice.Audio{}, providerSignal("", true))
return
}