// 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` 同一家族口径。 // // 🔴 **刻意没有 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"` // Character 是角色(服务端 `/api/characters` 的 id 或名字)。 // 空 = 代码默认 `char_84fb88a3`(主人指定的 Cyrene)。 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 } // 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 }