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

+41 -8
View File
@@ -38,8 +38,12 @@ const (
// DefaultBaseURL 是该服务的默认地址(**只绑回环**、无鉴权:绝不是公网地址)。
DefaultBaseURL = "http://127.0.0.1:9880"
// DefaultCharacter 是默认角色 —— 主人指定的「Cyrene」(`GET /api/characters`
// 实测 id = `char_84fb88a3`)。
// DefaultCharacter 只是**示例/文档用**的角色 id(主人指定的「Cyrene」,
// `GET /api/characters` 实测 id = `char_84fb88a3`)。
//
// 🔴 它**不是**代码里的兜底发音人:把任何角色 id 写死在代码里,正是主人
// 2026-10-26 点出的病害(换了人格仍用老角色的声音)。发音人一律由
// `Config.Personas` / `Config.Default` 显式给出(见 Resolve)。
DefaultCharacter = "char_84fb88a3"
// DefaultFormat 是默认请求格式。服务端产物**恒为 WAV**(它把 .wav 落盘后用
@@ -66,14 +70,23 @@ const (
// `$DATA_DIR/config/voice-tts.json` 映射而来;零新 `.env` 键)。
//
// 🔴 刻意**没有** speed / emotion / tone 三项,理由见包注释(上游硬编码或自推)。
// 🔴 也**没有全局 character**:发音人只能由 `Personas`(按会话角色映射)或
// `Default`(仅服务"解析不到 personaId")给出 —— 主人 2026-10-26 的硬要求
// 「TTS 的人物选择要绑定后端的角色,不然换了人格还是用老角色的声音」。
type Config struct {
// Enabled 是总开关。false = 该级整体不参与(调用方应保留既有链,
// 而不是把"没启用"当成"合成失败")。
Enabled bool
// BaseURL 是服务地址;空 = DefaultBaseURL。约定只填回环地址。
BaseURL string
// Character 是角色(服务端 `/api/characters` 的 id 或名字);空 = DefaultCharacter。
Character string
// Personas 是 **personaId → 上游 characterId** 的唯一映射源(也接受
// `char_` 前缀或不带前缀的写法,见 Resolve)。它有键但值为空 = 该角色
// 没有可用发音人 ⇒ 不合成(**绝不**借用别的角色的声音)。
Personas map[string]string
// Default 是**仅当 personaId 解析不到**(空串:会话无绑定 / 读取失败 /
// 该出口本身没有会话)时使用的发音人。它**不**服务"配了 Personas 但没有
// 这个 persona 的键"那种情况 —— 那是 Resolve 的拒绝分支(主人点名的病害)。
Default string
// Format 是请求格式(`response_format`);空 = DefaultFormat。只接受
// wav/mp3/ogg —— 且**只用于请求**,回发的真实格式一律按字节嗅探。
Format string
@@ -88,16 +101,17 @@ var supportedFormats = map[string]bool{"wav": true, "mp3": true, "ogg": true}
// Normalized 返回填好默认值、去掉首尾空白的配置。非法 Format 回落到默认值并
// 通过 log 记一条 WARN(**不让它停摆** —— 一个写错的格式不该关掉整个语音面)。
//
// 它**不**给 `Personas`/`Default` 补任何默认角色:发音人只有两种合法来源
// (显式映射 / 显式 default),代码里的兜底值就是主人担心的病害本身。
func (c Config) Normalized(log *slog.Logger) Config {
out := c
out.BaseURL = strings.TrimRight(strings.TrimSpace(out.BaseURL), "/")
if out.BaseURL == "" {
out.BaseURL = DefaultBaseURL
}
out.Character = strings.TrimSpace(out.Character)
if out.Character == "" {
out.Character = DefaultCharacter
}
out.Personas = normalizePersonas(out.Personas)
out.Default = strings.TrimSpace(out.Default)
out.Format = strings.ToLower(strings.TrimSpace(out.Format))
if out.Format == "" {
out.Format = DefaultFormat
@@ -113,3 +127,22 @@ func (c Config) Normalized(log *slog.Logger) Config {
}
return out
}
// normalizePersonas 去掉映射两端的空白与空键(空键不可能是合法 personaId),
// 并保留"键在、值为空"这一形态 —— 它表示"这个角色明确没有发音人",与"没这个键"
// 在 Resolve 里是**不同**的分支(前者拒绝,后者也拒绝;两者都绝不借用别的角色)。
func normalizePersonas(in map[string]string) map[string]string {
if len(in) == 0 {
return nil
}
out := make(map[string]string, len(in))
for persona, character := range in {
key := strings.TrimSpace(persona)
if key == "" {
continue
}
out[key] = strings.TrimSpace(character)
}
return out
}