Files
AskaEth 6906d274fc feat(voice): M47 后端启动后探活 emotionTTS,未起则异步拉起一键启动脚本
主人 2026-10-24:「tts启动脚本在这 ...\一键启动.bat,以后启动后端可以顺便检测
一下如果TTS没起就顺带启一下」。本片只做「启动时顺带探一下」,**默认关**(主人
早前说过「开机自启先不急 后面做」),未开开关时连探活都不发。

- 新包 internal/runtime/voice/ttslaunch:探 http://127.0.0.1:9880(毫秒级超时),
  已在跑什么都不做;没起则异步起脚本(cmd.exe /c 传参,不拼命令行字符串)
- 防重复:in-flight 单飞 + 一次尝试后的冷却(失败也算一次,重启风暴不会变成重试风暴)
- 失败只 WARN:探活超时/脚本不存在/起进程失败都只记带 source 的日志,
  组合根那条路径**没有 error 通道**
- 配置走面板文件 $DATA_DIR/config/voice-tts-launch.json(零新 .env 键)
- 接线在 cmd/cyrene 的网关 Listen 之后,整段在后台 goroutine 里,不占启动时长
  (用例以「挂载耗时 < 150ms 而探活超时 800ms」证明)
- 不新开端点、不加帧族;不碰 gateway 包
2026-10-26 18:47:36 +08:00

124 lines
6.4 KiB
Go
Raw Permalink 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.
// 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
}