Files
Cyrene_v2/internal/config/runtimeconfig/voicetts.go
T
AskaEth e684061e56 fix(voice): 主/子系统会话的发音人跟「生效人格」走(r2 P2-1)+ 错误码可区分
P2-1(实质):主会话(web)的 persona_id 恒为空且无重绑路径,而"空"的真实语义是
「继承全局默认人格」(loop/persona.go 回退 Options.PersonaID;前端 lib/persona.ts 的
bound ?? defaultPersona(personas) 是同一口径)。此前只有 TTS 把空读成"没有生效人格"
⇒ 落到 voice-tts.json 的 default:主会话聊天是 furina、声音却是 default 那个人,
换全局人格后也不跟声 —— 正是主人抱怨的那件事,且发生在唯一能给普通聊天出声的路径。

修法:
- turnEnvResolver 新增 resolvePersona/effectivePersona(绑定 → 全局默认人格 → ""),
  默认人格扫描抽成 defaultPersonaIDFrom 一处(*Server.defaultPersonaID 转调它);
- ownerWithPersona 复用**同一次**会话读取同时给出 loop 用的 binding 与呈现用的
  effective —— WS 语音回合为选音色不新增任何 I/O;
- REST:现有闸内改用 effectivePersona;WS:voice.go 那次 env 解析处取一次传给 sink。
因此 `default` 只剩"连默认人格都没有"这一种用途。
- service.go 那句"换人格后用老角色声音不可能发生"收回为限定说法:本包无缓存、无兜底
  角色、未映射即拒绝,但**调用方必须传生效人格**(网关两处已修)。

回归锁:voice_persona_inherit_test.go 复现复审用例(名字不同、两条口各一条)——
未绑定会话 + 全局默认 furina + default 指向 cyrene ⇒ 必须用 furina 的声音;
变异(effectivePersona 退回「空 = 无」)实测两条都红,报文与复审一致。

建议 3(顺手做):persona 未映射时状态码仍 500(服务端配置缺失),但错误码换成
tts_persona_unmapped / tts_no_character,不再与一般合成失败混成一个码;
用例 TestSpeechEndpointPersonaUnmappedErrorCode 钉住。

P2-2(nit):voicetts.go 开头删掉「格式、」两字(该键已删除)。

go build ./... + go vet ./... + go test(含 -race)全绿。
2026-10-26 13:21:44 +08:00

145 lines
8.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// voicetts.go 是 **emotionTTS 语音合成**(切片 M35)的可变源:
// `$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`
// 同一家族口径。
//
// 🔴 **发音人必须绑定人格**(主人 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` 的
// `synthesize_audio`),客户端传的 speed 是空转 ⇒ 做一个键就是做一个**假开关**;
// - 情绪由上游**自己**推(LLM 分析待念文本 + 匹配参考音 + 注入情绪向量),我方
// 没有可映射的参数 ⇒ 不做"情绪 → 语气"映射表(有映射表也只是自嗨)。
//
// 边界(与 backthink.go / memorytidy.go 同源的手续):
//
// 1. 本文件只管**文件形状 + 读取**:它不认识 `tts.Config`,也不做"缺省填什么值"
// 的裁决(那是 `tts.Config.Normalized` 的事)。
// 2. **没有"面板编辑"的写口**:改内容仍要**人手改文件 + 重启**(与
// `backthink.json` 同口径:启动期读一次)。本切片不新增播种文件 —— 文件不存在
// 就是"没配过",该级不启用。
// 3. **零新 env 键**,也不进面板的配置事实卡(那张表的键名被 `.env.example` 钉住)。
//
// 读取口径:文件缺失或全空白 = 还没配过(零值 + nil,**不是错误**);存在但解不开
// = 返回错误,调用方记 WARN 并**回落到"不启用"**(一个坏文件绝不能把语音面变成
// 「半开」状态 —— 宁可没有这一级,也不要用一份读不懂的配置去调外部服务)。
package runtimeconfig
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
const (
// voiceTTSFileName 是文件名(家族命名 = 服务/模块名 + .json)。
voiceTTSFileName = "voice-tts.json"
// voiceTTSConfigSubdir 是落点的第一段目录(`$DATA_DIR/config/`)。与其它族
// 各自声明一份,避免某一族换位置时另一处静默跟着搬家。
voiceTTSConfigSubdir = "config"
)
// VoiceTTSConfigRelPath 是落点相对 `$DATA_DIR` 的路径(`config/voice-tts.json`)。
// 导出它是为了让面板/文档能**如实**告诉主人改哪个文件。
const VoiceTTSConfigRelPath = voiceTTSConfigSubdir + "/" + voiceTTSFileName
// ErrInvalidVoiceTTS 是 voice-tts.json 结构不合法时的分类错误(errors.Is 可判类)。
var ErrInvalidVoiceTTS = errors.New("runtimeconfig: invalid voice tts config")
// VoiceTTSTuning 是 `voice-tts.json` 的形状:emotionTTS 那个发音人的运维旋钮。
//
// 字段名是 camelCase 的 JSON 键(文件是给人看、给人改的)。
type VoiceTTSTuning struct {
// Enabled 控制这一级是否参与合成。nil = 没配(**不启用**);true = 启用;
// false = 显式关掉(与"没配"同效,写它只是为了自解释)。
//
// 用**指针**是刻意的:`bool` 的零值 false 与"键没写"不可区分,而这一级的默认
// 语义是**关**(它要连一台默认在本机回环上的外部服务,不能靠"猜"打开)。
// 这里指针带来的是"没写"与"写了 false"在日志里可区分,行为上二者都等于不启用。
Enabled *bool `json:"enabled"`
// BaseURL 是服务地址。空 = 代码默认 `http://127.0.0.1:9880`。
//
// 🔴 该服务**无鉴权、只绑回环**:这里填公网地址等于把一个无鉴权的合成口暴露给
// 网络,安全上由部署方自行负责(本文件不替人做这个决定,也不给默认值以外的暗示)。
BaseURL string `json:"baseUrl,omitempty"`
// 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"`
// TimeoutSec 是单次合成超时(**秒**)。零/负 = 没配 ⇒ 代码默认 60 秒。
//
// 用秒而不是 Go 时长字面量:这个文件是给人改的,写 `60` 比要求记住 `"60s"`
// 更不容易写错,也与既有 `$DATA_DIR/config/**` 的 `*_SEC` 口径一致。
TimeoutSec int `json:"timeoutSec,omitempty"`
}
// 本文件**刻意没有** `format` 键:上游只拿 `response_format` 改它自报的 media_type,
// 产物恒 `.wav` 且无转码,我方又只按字节嗅探裁决 ⇒ 那个键不改变任何交付字节,
// 是假开关。请求体里它由 `tts.requestFormat` 固定为 wav。
// 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)
}
// LoadVoiceTTSTuning 读取 voice-tts.json(**只读**)。
//
// 文件缺失或全空白 ⇒ 零值 + nil:调用方据此得出"这一级没配过(不启用)",
// **不是**错误 —— 一台没装 emotionTTS 的机器不该每次启动都刷 WARN。
//
// 存在但解不开 ⇒ 返回包装了 ErrInvalidVoiceTTS 的错误。调用方应记一条带 source 的
// WARN 并**回落"不启用"**(见文件头第 3 条);**绝不得覆盖**那个文件(损坏的文件
// 可以人工救回,程序盖掉它就丢了人工写的意图)。
func LoadVoiceTTSTuning(path string) (VoiceTTSTuning, error) {
raw, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return VoiceTTSTuning{}, nil
}
return VoiceTTSTuning{}, fmt.Errorf("runtimeconfig: read %s: %w", path, err)
}
if strings.TrimSpace(string(raw)) == "" {
return VoiceTTSTuning{}, nil
}
var t VoiceTTSTuning
if err := json.Unmarshal(raw, &t); err != nil {
return VoiceTTSTuning{}, fmt.Errorf("%w: decode %s: %w", ErrInvalidVoiceTTS, path, err)
}
return t, nil
}