主人 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。
146 lines
8.3 KiB
Go
146 lines
8.3 KiB
Go
// 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"`
|
||
// Format 是请求格式,只接受 wav/mp3/ogg。空 = 代码默认 wav。
|
||
//
|
||
// 注意:上游产物**恒为 WAV**(它按 `response_format` 自报 media_type,但字节是
|
||
// WAV)⇒ 真正下发的格式一律由字节嗅探裁决,这一键只影响请求参数。
|
||
Format string `json:"format,omitempty"`
|
||
// TimeoutSec 是单次合成超时(**秒**)。零/负 = 没配 ⇒ 代码默认 60 秒。
|
||
//
|
||
// 用秒而不是 Go 时长字面量:这个文件是给人改的,写 `60` 比要求记住 `"60s"`
|
||
// 更不容易写错,也与既有 `$DATA_DIR/config/**` 的 `*_SEC` 口径一致。
|
||
TimeoutSec int `json:"timeoutSec,omitempty"`
|
||
}
|
||
|
||
// 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
|
||
}
|