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 包
This commit is contained in:
1 parent
af61b8ccfe
commit
6906d274fc
13 files changed
+1354
No files matched your search
@@ -0,0 +1,123 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package runtimeconfig
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestLoadVoiceTTSLaunchTuning 锁定 voice-tts-launch.json 的四条读取口径:
|
||||
// 缺失 = 没配过(不是错误、**不启用**)、全空白 = 没配过、合法 = 逐键读出、
|
||||
// 坏文件 = 可分类错误(调用方据此回落"不启用")。
|
||||
func TestLoadVoiceTTSLaunchTuning(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "voice-tts-launch.json")
|
||||
|
||||
// ① 缺失 ⇒ 零值 + nil(默认关:这把开关会起进程,不能靠"猜"打开)
|
||||
if tuning, err := LoadVoiceTTSLaunchTuning(path); err != nil || tuning.EnabledOn() {
|
||||
t.Fatalf("缺失: tuning=%+v err=%v, want 零值 + nil", tuning, err)
|
||||
}
|
||||
|
||||
// ② 全空白 ⇒ 同样当作没配过
|
||||
if err := os.WriteFile(path, []byte(" \n\t "), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if tuning, err := LoadVoiceTTSLaunchTuning(path); err != nil || tuning.EnabledOn() {
|
||||
t.Fatalf("空白: tuning=%+v err=%v, want 零值 + nil", tuning, err)
|
||||
}
|
||||
|
||||
// ③ 合法 ⇒ 逐键读出(脚本路径含中文,正是主人那台机器的形状)
|
||||
body := `{"enabled":true,"scriptPath":"D:\\Project\\Service\\emotionTTS_v5.1.0\\一键启动.bat",
|
||||
"probeUrl":"http://127.0.0.1:9880","probeTimeoutMs":250,"cooldownSec":90}`
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
tuning, err := LoadVoiceTTSLaunchTuning(path)
|
||||
if err != nil {
|
||||
t.Fatalf("合法文件: %v", err)
|
||||
}
|
||||
if !tuning.EnabledOn() {
|
||||
t.Fatal("enabled:true 没被读出")
|
||||
}
|
||||
if tuning.ProbeTimeoutMs != 250 || tuning.CooldownSec != 90 {
|
||||
t.Fatalf("超时/冷却没读出: %+v", tuning)
|
||||
}
|
||||
if tuning.ProbeURL != "http://127.0.0.1:9880" {
|
||||
t.Fatalf("probeUrl = %q", tuning.ProbeURL)
|
||||
}
|
||||
if filepath.Base(tuning.ScriptPath) != "一键启动.bat" {
|
||||
t.Fatalf("scriptPath 没读出: %q", tuning.ScriptPath)
|
||||
}
|
||||
|
||||
// ④ 显式 false ⇒ 与"没配过"同效(EnabledOn 为 false)
|
||||
if err := os.WriteFile(path, []byte(`{"enabled":false}`), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if tuning, err = LoadVoiceTTSLaunchTuning(path); err != nil || tuning.EnabledOn() {
|
||||
t.Fatalf("显式 false: tuning=%+v err=%v, want 不启用", tuning, err)
|
||||
}
|
||||
|
||||
// ⑤ 坏文件 ⇒ 分类错误(调用方据此 WARN + 不启用),且绝不覆盖原文件
|
||||
if err := os.WriteFile(path, []byte(`{"enabled":`), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if _, err = LoadVoiceTTSLaunchTuning(path); !errors.Is(err, ErrInvalidVoiceTTSLaunch) {
|
||||
t.Fatalf("坏文件: err=%v, want ErrInvalidVoiceTTSLaunch", err)
|
||||
}
|
||||
if raw, rerr := os.ReadFile(path); rerr != nil || string(raw) != `{"enabled":` {
|
||||
t.Fatalf("坏文件被改写了: %q err=%v", raw, rerr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultVoiceTTSLaunchPath 钉住落点:`$DATA_DIR/config/voice-tts-launch.json`
|
||||
// —— 它是"零新 .env 键"这一条的物理形态(开关只可能来自面板文件)。
|
||||
func TestDefaultVoiceTTSLaunchPath(t *testing.T) {
|
||||
got := DefaultVoiceTTSLaunchPath("/data")
|
||||
want := filepath.Join("/data", "config", "voice-tts-launch.json")
|
||||
if got != want {
|
||||
t.Fatalf("落点 = %q, want %q", got, want)
|
||||
}
|
||||
if VoiceTTSLaunchConfigRelPath != "config/voice-tts-launch.json" {
|
||||
t.Fatalf("相对路径 = %q", VoiceTTSLaunchConfigRelPath)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user