// voice_tts_launch.go 是 **emotionTTS 自启**(切片 M47)的可变源: // `$DATA_DIR/config/voice-tts-launch.json`(与 `voice-tts.json` / `backthink.json` // 同一家族口径,design/13 §2.1:调优/门槛/上限/开关类参数走 `$DATA_DIR/config/` + // 代码默认值;`.env` 已冻结,**零新 env 键**)。 // // 为什么需要它:主人 2026-10-24 逐字「tts启动脚本在这 // `D:\Project\Service\emotionTTS_v5.1.0\一键启动.bat`,以后启动后端可以顺便检测 // 一下如果TTS没起就顺带启一下」。这是**一条运维便利**,不是后端的一部分: // // - **默认关**(nil / false 都等于不启用)。主人早前说过「开机自启先不急 后面做」 // ⇒ 未显式打开时,后端连一次探活都不发,更不会拉起任何进程。 // - 拉起动作整段在后台 goroutine 里(见 `internal/runtime/voice/ttslaunch`), // **不占**后端启动时长;失败只 WARN,绝不阻塞或改坏后端启动路径。 // // 与 `voice-tts.json` 分开而不是塞进同一份文件:那一份管「合成怎么调」(地址/ // 角色映射/超时),这一份管「服务没起时要不要顺手拉一下」。两件事的失败语义、 // 默认值、被谁读都不同(合成面读一次决定注入与否;本文件读一次决定是否起进程), // 合在一份会让"只想开合成"的人被迫接触进程拉起这个更重的开关。 // // 边界(与 voicetts.go 同源的手续): // // 1. 本文件只管**文件形状 + 读取**:不认识 `ttslaunch.Config`,也不裁决"缺省填 // 什么值"(那是 `ttslaunch.Config.Normalized` 的事)。 // 2. **没有"面板编辑"的写口**:改内容仍要人手改文件 + 重启(启动期读一次)。 // 3. **零新 env 键**,也不进面板的配置事实卡。 // // 读取口径:文件缺失或全空白 = 还没配过(零值 + nil,**不是错误**);存在但解不开 // = 返回分类错误,调用方记 WARN 并**回落"不启用"**(一份读不懂的配置绝不能变成 // 「随手拉起进程」)。 package runtimeconfig import ( "encoding/json" "errors" "fmt" "os" "path/filepath" "strings" ) const ( // voiceTTSLaunchFileName 是文件名(家族命名 = 模块名 + .json)。 voiceTTSLaunchFileName = "voice-tts-launch.json" // voiceTTSLaunchConfigSubdir 是落点的第一段目录(`$DATA_DIR/config/`)。 voiceTTSLaunchConfigSubdir = "config" ) // VoiceTTSLaunchConfigRelPath 是落点相对 `$DATA_DIR` 的路径 // (`config/voice-tts-launch.json`)。导出它是为了让日志/文档能**如实**告诉主人 // 改哪个文件。 const VoiceTTSLaunchConfigRelPath = voiceTTSLaunchConfigSubdir + "/" + voiceTTSLaunchFileName // ErrInvalidVoiceTTSLaunch 是 voice-tts-launch.json 结构不合法时的分类错误 // (errors.Is 可判类)。 var ErrInvalidVoiceTTSLaunch = errors.New("runtimeconfig: invalid voice tts launch config") // VoiceTTSLaunchTuning 是 `voice-tts-launch.json` 的形状:启动期「探一下、没起 // 就顺手拉起」的几个旋钮。 // // 字段名是 camelCase 的 JSON 键(文件是给人看、给人改的)。 type VoiceTTSLaunchTuning struct { // Enabled 控制后端启动时是否顺带探活/拉起 emotionTTS。nil = 没配 // (**不启用**);true = 启用;false = 显式关掉(与"没配"同效,写它只是为了 // 自解释)。 // // 用**指针**是刻意的:`bool` 的零值 false 与"键没写"不可区分,而这一级的默认 // 语义是**关**(它会**起进程**,是比"调个超时"重得多的副作用,不能靠"猜"打开)。 Enabled *bool `json:"enabled"` // ScriptPath 是拉起用的启动脚本(主人的 `一键启动.bat`)。 // 空 = 代码默认(`ttslaunch.DefaultScriptPath`)。 // // 脚本不存在只 WARN(这台机器上没装 emotionTTS 是正常形态),不是错误。 ScriptPath string `json:"scriptPath,omitempty"` // ProbeURL 是探活地址。空 = 代码默认 `http://127.0.0.1:9880`。 // // 它回答的问题是「这个端口上已经有人在应答吗」——**任何** HTTP 回应都算在跑 // (见 ttslaunch 的探活口径),因此填一个别的服务的地址只会让它永远"已在跑"。 ProbeURL string `json:"probeUrl,omitempty"` // ProbeTimeoutMs 是单次探活超时(**毫秒**)。零/负 = 代码默认。 // // 它决定的是「等多久才认定没起」:这是启动**之后**的后台动作,但过大的值只会让 // "没装 TTS"的机器白等,过小则可能把"正在初始化"误判成"没起"。 ProbeTimeoutMs int `json:"probeTimeoutMs,omitempty"` // CooldownSec 是**一次拉起尝试之后的冷却**(**秒**)。零/负 = 代码默认。 // // 它的用途是防「后端重启风暴」:主人连点重启时,每次都去拉一个已在启动中的 // TTS 只会堆出多个抢同一个端口的进程。 CooldownSec int `json:"cooldownSec,omitempty"` } // EnabledOn 报告自启是否被显式打开(nil = 没配 ⇒ 不启用)。 func (t VoiceTTSLaunchTuning) EnabledOn() bool { return t.Enabled != nil && *t.Enabled } // DefaultVoiceTTSLaunchPath 返回落点:`$DATA_DIR/config/voice-tts-launch.json`。 func DefaultVoiceTTSLaunchPath(dataDir string) string { return filepath.Join(dataDir, voiceTTSLaunchConfigSubdir, voiceTTSLaunchFileName) } // LoadVoiceTTSLaunchTuning 读取 voice-tts-launch.json(**只读**)。 // // 文件缺失或全空白 ⇒ 零值 + nil:调用方据此得出"没配过(不启用)",**不是**错误 // —— 绝大多数机器不装 emotionTTS,不该每次启动都刷 WARN。 // // 存在但解不开 ⇒ 返回包装了 ErrInvalidVoiceTTSLaunch 的错误。调用方应记一条带 // source 的 WARN 并**回落"不启用"**;**绝不得覆盖**那个文件(人工救得回来, // 程序盖掉就丢了人写的意图)。 func LoadVoiceTTSLaunchTuning(path string) (VoiceTTSLaunchTuning, error) { raw, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return VoiceTTSLaunchTuning{}, nil } return VoiceTTSLaunchTuning{}, fmt.Errorf("runtimeconfig: read %s: %w", path, err) } if strings.TrimSpace(string(raw)) == "" { return VoiceTTSLaunchTuning{}, nil } var t VoiceTTSLaunchTuning if err := json.Unmarshal(raw, &t); err != nil { return VoiceTTSLaunchTuning{}, fmt.Errorf("%w: decode %s: %w", ErrInvalidVoiceTTSLaunch, path, err) } return t, nil }