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

+34 -5
View File
@@ -2,9 +2,16 @@
// `$DATA_DIR/config/voice-tts.json`(design/13 §2.1「运行期生成的配置进
// `$DATA_DIR/config/`;调优/门槛/上限/开关类参数走这里 + 代码默认值」)。
//
// 为什么需要它:把半云端的 emotionTTS 接成她的一级发音人,需要三个运维旋钮 ——
// 开关、地址、角色;而 `.env` 已被主人冻结(**零新 env 键**,design/13 §2.1),
// 所以它们落在这里,与 `memory-tidy.json` / `backthink.json` 同一家族口径。
// 为什么需要它:把半云端的 emotionTTS 接成她的一级发音人,需要几项运维旋钮 ——
// 开关、地址、**角色映射**、格式、超时;而 `.env` 已被主人冻结(**零新 env 键**,
// design/13 §2.1),所以它们落在这里,与 `memory-tidy.json` / `backthink.json`
// 同一家族口径。
//
// 🔴 **发音人必须绑定人格**(主人 2026-10-26 逐字:「TTS 的人物选择是要绑定后端的
// 角色的,不然会出现以后换了别的角色(人格配置),还是用老角色的 TTS 角色」):
// 因此本文件的核心是 `personas`(personaId → characterId)与 `default`(**仅**
// 服务"解析不到 personaId")。全局 `character` 键已废弃并被忽略(见字段注释)。
// 映射的**唯一真源**就是本文件 —— `configs/personas/*.yaml` 不参与。
//
// 🔴 **刻意没有 speed / emotion / tone 三键**(塔 2026-10-26 裁决,实测证据):
// - 上游把下游载荷的 `speed` **硬编码 1.0**(`core/utils/llm_provider.py` 的
@@ -66,8 +73,26 @@ type VoiceTTSTuning struct {
// 🔴 该服务**无鉴权、只绑回环**:这里填公网地址等于把一个无鉴权的合成口暴露给
// 网络,安全上由部署方自行负责(本文件不替人做这个决定,也不给默认值以外的暗示)。
BaseURL string `json:"baseUrl,omitempty"`
// Character 是角色(服务端 `/api/characters` 的 id 或名字)。
// 空 = 代码默认 `char_84fb88a3`(主人指定的 Cyrene)。
// Personas 是 **personaId → 上游 characterId** 的唯一映射源(主人 2026-10-26:
// 「TTS 的人物选择是要绑定后端的角色的」)。
//
// - 这是**唯一真源**:`configs/personas/*.yaml` 不参与(启动期静态配置 + 面板
// 可热调,两处必然漂移)。
// - 有键但值为空串 = 该角色明确没有发音人 ⇒ 不合成(绝不借用别的角色)。
// - **没有这个键** ⇒ 同样不合成,且**不**回落到下面的 `default`。
Personas map[string]string `json:"personas,omitempty"`
// Default 是**仅当 personaId 解析不到**(会话无绑定 / 读取失败 / 该出站口本身
// 没有会话)时使用的发音人;空 = 没配 ⇒ 那种情况**不合成**。
//
// 🔴 它**不服务**「配了 personas 但没有这个 persona 的键」——那种情况一律不合成
// (见 `tts.Config.Resolve` 的拒绝分支)。用 default 去顶正是"换了人格还用老角色
// 声音"的病害本身。
Default string `json:"default,omitempty"`
// Character 是**已废弃**的全局发音人键(切片第一版)。
//
// 🔴 它被**忽略**:全局一个 character 正是主人点名的缺陷(换人格仍用老声音)。
// 保留字段只为在启动日志里明确告诉运维"这一键不再生效,请改用 personas",
// 而不是让它被 json 静默丢弃。新配置**不要**写它。
Character string `json:"character,omitempty"`
// Format 是请求格式,只接受 wav/mp3/ogg。空 = 代码默认 wav。
//
@@ -84,6 +109,10 @@ type VoiceTTSTuning struct {
// EnabledOn 报告这一级是否被显式打开(nil = 没配 ⇒ 不启用)。
func (t VoiceTTSTuning) EnabledOn() bool { return t.Enabled != nil && *t.Enabled }
// LegacyGlobalCharacter 返回已废弃的全局 `character` 键(未写时为空)。调用方应
// 记一条 WARN 说明"这一键不再生效,改用 personas",**绝不**把它当发音人使用。
func (t VoiceTTSTuning) LegacyGlobalCharacter() string { return strings.TrimSpace(t.Character) }
// DefaultVoiceTTSPath 返回落点:`$DATA_DIR/config/voice-tts.json`。
func DefaultVoiceTTSPath(dataDir string) string {
return filepath.Join(dataDir, voiceTTSConfigSubdir, voiceTTSFileName)
+26 -4
View File
@@ -26,9 +26,10 @@ func TestLoadVoiceTTSTuning(t *testing.T) {
t.Fatalf("空白: tuning=%+v err=%v, want 零值 + nil", tuning, err)
}
// ③ 合法
body := `{"enabled":true,"baseUrl":"http://127.0.0.1:9880","character":"char_84fb88a3",
"format":"wav","timeoutSec":45}`
// ③ 合法(新形状:personas 映射 + default;旧的全局 character 不再参与)
body := `{"enabled":true,"baseUrl":"http://127.0.0.1:9880",
"personas":{"cyrene":"char_84fb88a3","furina":"char_5314489e"},
"default":"char_84fb88a3","format":"wav","timeoutSec":45}`
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
@@ -36,9 +37,30 @@ func TestLoadVoiceTTSTuning(t *testing.T) {
if err != nil {
t.Fatalf("合法文件: %v", err)
}
if !tuning.EnabledOn() || tuning.Character != "char_84fb88a3" || tuning.Format != "wav" || tuning.TimeoutSec != 45 {
if !tuning.EnabledOn() || tuning.Format != "wav" || tuning.TimeoutSec != 45 {
t.Fatalf("读取结果不对: %+v", tuning)
}
if len(tuning.Personas) != 2 || tuning.Personas["furina"] != "char_5314489e" {
t.Fatalf("personas 没有被读出: %+v", tuning.Personas)
}
if tuning.Default != "char_84fb88a3" {
t.Fatalf("default = %q", tuning.Default)
}
// ③b 旧的全局 character 仍能读出来(供启动日志提示"已废弃"),但它**不是**
// personas/default 的一部分 —— 组合根绝不把它当发音人。
if err := os.WriteFile(path, []byte(`{"enabled":true,"character":"char_old"}`), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
if tuning, err = LoadVoiceTTSTuning(path); err != nil {
t.Fatalf("旧键: %v", err)
}
if tuning.LegacyGlobalCharacter() != "char_old" {
t.Fatalf("LegacyGlobalCharacter = %q", tuning.LegacyGlobalCharacter())
}
if len(tuning.Personas) != 0 || tuning.Default != "" {
t.Fatalf("旧全局 character 混进了 personas/default: %+v", tuning)
}
// ④ 显式 false 与"没配"同效,但指针让它在读取侧可区分。
if err := os.WriteFile(path, []byte(`{"enabled":false}`), 0o600); err != nil {