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
@@ -125,6 +125,13 @@ func run() error {
|
||||
return fmt.Errorf("start gateway: %w", err)
|
||||
}
|
||||
|
||||
// emotionTTS 自启(切片 M47):**在网关 Listen 之后**才挂 —— 网关的
|
||||
// ListenAndServe 已交给自己的 goroutine,这里只投递一次「探活 → 必要时拉起」,
|
||||
// 整段(毫秒级探活 + 起一个 .bat)都在后台跑,因此**不占**后端启动时长:走到
|
||||
// 这里时进程已经可以服务。开关默认关(面板文件 voice-tts-launch.json),
|
||||
// 失败只 WARN,绝不影响后端启动与退出。
|
||||
a.startVoiceTTSLaunch(ctx)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
a.log.Info("cyrene signal received, shutting down")
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package main
|
||||
|
||||
// 本文件把**后端启动时顺带照顾 emotionTTS**(切片 M47)接进组合根,并守住三条
|
||||
// 红线(塔 2026-10 已定,主人原话见 internal/runtime/voice/ttslaunch 包注释):
|
||||
//
|
||||
// 1. **默认关**:面板文件 `$DATA_DIR/config/voice-tts-launch.json` 没写
|
||||
// `{"enabled": true}` 时,这里**连探活都不发**、也不构造任何后台任务 ——
|
||||
// 主人早前说过「开机自启先不急 后面做」。
|
||||
// 2. **不占启动时长**:`Trigger` 把探活 + 起脚本整段丢进后台 goroutine 后立即
|
||||
// 返回,调用点在网关 Listen 之后(见 main.run);探活超时/脚本拉起耗时都
|
||||
// 落不到后端的启动路径上。
|
||||
// 3. **失败只 WARN**:脚本不存在、起进程失败、探活超时都只是带 source 的日志,
|
||||
// 本函数**没有 error 返回**,绝不阻塞或改坏后端启动。
|
||||
//
|
||||
// 零新 `.env` 键(`.env` 已冻结):开关与脚本路径都走面板文件家族
|
||||
// (`runtimeconfig`,与 `voice-tts.json` 同一套读取口径)。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/ttslaunch"
|
||||
)
|
||||
|
||||
// voiceTTSLaunchSource 是本切片日志的来源标签(AGENTS §10:日志必须标来源)。
|
||||
const voiceTTSLaunchSource = "cmd/cyrene/voice-tts-launch"
|
||||
|
||||
// startVoiceTTSLaunch 读面板配置,按需投递**一次**「探活 → 必要时拉起」。
|
||||
//
|
||||
// 返回 nil 表示这一级没启用(没配过 / 显式 false / 文件坏掉),调用方据此什么都
|
||||
// 不做;返回非 nil 仅为了让调用方在关闭期或验收里能 `Wait()`(生产路径忽略它)。
|
||||
func (a *app) startVoiceTTSLaunch(ctx context.Context) *ttslaunch.Launcher {
|
||||
log := logger.WithSource(a.log, voiceTTSLaunchSource)
|
||||
path := runtimeconfig.DefaultVoiceTTSLaunchPath(resolveDataDir(a.cfg))
|
||||
tuning, err := runtimeconfig.LoadVoiceTTSLaunchTuning(path)
|
||||
if err != nil {
|
||||
// 坏文件 ⇒ **不启用**:一份读不懂的配置绝不能变成"随手拉起一个进程"。
|
||||
log.Warn("voice tts launch config unreadable, emotionTTS auto-launch stays off",
|
||||
"path", runtimeconfig.VoiceTTSLaunchConfigRelPath, "err", err)
|
||||
return nil
|
||||
}
|
||||
if !tuning.EnabledOn() {
|
||||
log.Debug("emotionTTS auto-launch not enabled in panel config",
|
||||
"path", runtimeconfig.VoiceTTSLaunchConfigRelPath, "hint", `set {"enabled": true}`)
|
||||
return nil
|
||||
}
|
||||
launcher := ttslaunch.New(voiceTTSLaunchConfig(tuning), ttslaunch.WithLogger(log))
|
||||
cfg := launcher.Config()
|
||||
log.Info("emotionTTS auto-launch enabled",
|
||||
"probe_url", cfg.ProbeURL,
|
||||
"script", cfg.ScriptPath,
|
||||
"probe_timeout_ms", cfg.ProbeTimeout.Milliseconds(),
|
||||
"cooldown_sec", int(cfg.Cooldown/time.Second),
|
||||
"config", runtimeconfig.VoiceTTSLaunchConfigRelPath)
|
||||
// 立即返回:探活与起脚本都在后台跑(见 ttslaunch.Launcher.Trigger)。
|
||||
launcher.Trigger(ctx)
|
||||
return launcher
|
||||
}
|
||||
|
||||
// voiceTTSLaunchConfig 把面板形状映射成 launcher 的配置。
|
||||
//
|
||||
// 它**不**在这里裁决缺省值(脚本路径/探活地址/超时/冷却的默认值全在
|
||||
// `ttslaunch.Config.Normalized` 一处):零值原样传下去,由那一处补齐。
|
||||
func voiceTTSLaunchConfig(t runtimeconfig.VoiceTTSLaunchTuning) ttslaunch.Config {
|
||||
return ttslaunch.Config{
|
||||
Enabled: t.EnabledOn(),
|
||||
ScriptPath: t.ScriptPath,
|
||||
ProbeURL: t.ProbeURL,
|
||||
ProbeTimeout: time.Duration(t.ProbeTimeoutMs) * time.Millisecond,
|
||||
Cooldown: time.Duration(t.CooldownSec) * time.Second,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package main
|
||||
|
||||
// 本文件锁定切片 M47 在**组合根**上的口径(都是"改坏就静默开错/静默拖慢启动"的那种):
|
||||
//
|
||||
// 1. 面板文件没配过 / 写了 `enabled:false` ⇒ **不启用**(返回 nil),连探活都不发;
|
||||
// 2. 启用后 `startVoiceTTSLaunch` **立即返回**,即使探活会挂住整整一个超时 ——
|
||||
// 证明 TTS 的延迟不计入后端启动时长(它是唯一"启动耗时"可测的量);
|
||||
// 3. 拉不起来(脚本不存在)⇒ 只有 WARN,**没有任何 error 回流**到启动路径。
|
||||
//
|
||||
// 它用 `DefaultVoiceTTSLaunchPath` 的真实落点
|
||||
// ($DATA_DIR/config/voice-tts-launch.json)跑,因此也钉住"零新 .env 键"。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/ttslaunch"
|
||||
)
|
||||
|
||||
// captureBuf 是并发安全的日志缓冲(WARN 由后台 goroutine 写)。
|
||||
type captureBuf struct {
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (b *captureBuf) Write(p []byte) (int, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.Write(p)
|
||||
}
|
||||
|
||||
func (b *captureBuf) String() string {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.String()
|
||||
}
|
||||
|
||||
// launchTestApp 造一个只够本题用的 app(数据目录 + 收日志的 logger)。
|
||||
func launchTestApp(t *testing.T, dataDir string) (*app, *captureBuf) {
|
||||
t.Helper()
|
||||
buf := &captureBuf{}
|
||||
log := slog.New(slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
return &app{cfg: &config.Config{DataDir: dataDir}, log: log}, buf
|
||||
}
|
||||
|
||||
// writeLaunchPanel 把面板文件写到真实落点($DATA_DIR/config/voice-tts-launch.json)。
|
||||
func writeLaunchPanel(t *testing.T, dataDir, body string) {
|
||||
t.Helper()
|
||||
path := runtimeconfig.DefaultVoiceTTSLaunchPath(dataDir)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatalf("write panel: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartVoiceTTSLaunch_DefaultOff 钉住"默认关":没配过 ⇒ 不构造、不探活、不拉起。
|
||||
func TestStartVoiceTTSLaunch_DefaultOff(t *testing.T) {
|
||||
a, buf := launchTestApp(t, t.TempDir())
|
||||
start := time.Now()
|
||||
if l := a.startVoiceTTSLaunch(context.Background()); l != nil {
|
||||
t.Fatal("没配过却启用了自启")
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 100*time.Millisecond {
|
||||
t.Fatalf("默认关也花了 %v", elapsed)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "not enabled") {
|
||||
t.Fatalf("没留一条「没启用」的痕迹: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartVoiceTTSLaunch_ExplicitFalse 钉住"显式 false 同样一次都不拉"。
|
||||
func TestStartVoiceTTSLaunch_ExplicitFalse(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeLaunchPanel(t, dir, `{"enabled":false}`)
|
||||
a, _ := launchTestApp(t, dir)
|
||||
if l := a.startVoiceTTSLaunch(context.Background()); l != nil {
|
||||
t.Fatal("enabled:false 却启用了自启")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartVoiceTTSLaunch_ReturnsBeforeProbe 是本切片的"启动耗时"证明:
|
||||
// 探活会挂住一个 800ms 的超时(对端只接连接不回话),而挂载动作必须在毫秒级返回。
|
||||
//
|
||||
// 同时它顺带钉住第 3 条:脚本不存在 ⇒ 后台只记 WARN,`startVoiceTTSLaunch` 自己
|
||||
// 没有任何 error 可返回(签名里就没有),后端启动路径不受影响。
|
||||
func TestStartVoiceTTSLaunch_ReturnsBeforeProbe(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
blackhole, addr := blackholeListener(t)
|
||||
defer blackhole.Close()
|
||||
|
||||
writeLaunchPanel(t, dir, fmt.Sprintf(
|
||||
`{"enabled":true,"probeUrl":%q,"probeTimeoutMs":800,"scriptPath":%q}`,
|
||||
"http://"+addr+"/", filepath.Join(dir, "不存在的一键启动.bat")))
|
||||
a, buf := launchTestApp(t, dir)
|
||||
|
||||
start := time.Now()
|
||||
launcher := a.startVoiceTTSLaunch(context.Background())
|
||||
elapsed := time.Since(start)
|
||||
if launcher == nil {
|
||||
t.Fatal("启用后应拿到 launcher")
|
||||
}
|
||||
if elapsed > 150*time.Millisecond {
|
||||
t.Fatalf("挂载花了 %v —— 探活被同步等在了启动路径上", elapsed)
|
||||
}
|
||||
// 后台那次探活会等到 800ms 超时,然后去拉一个不存在的脚本。
|
||||
launcher.Wait()
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "level=WARN") || !strings.Contains(out, "backend startup unaffected") {
|
||||
t.Fatalf("拉起失败没有只记 WARN: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVoiceTTSLaunchConfig_MapsTuning 钉住"面板字段 → launcher 配置"的映射:
|
||||
// 缺省值不由组合根裁决(空字段原样传下去,由 ttslaunch.Config.Normalized 补)。
|
||||
func TestVoiceTTSLaunchConfig_MapsTuning(t *testing.T) {
|
||||
enabled := true
|
||||
cfg := voiceTTSLaunchConfig(runtimeconfig.VoiceTTSLaunchTuning{
|
||||
Enabled: &enabled,
|
||||
ScriptPath: `D:\Project\Service\emotionTTS_v5.1.0\一键启动.bat`,
|
||||
ProbeURL: "http://127.0.0.1:9880",
|
||||
ProbeTimeoutMs: 250,
|
||||
CooldownSec: 90,
|
||||
})
|
||||
if !cfg.Enabled {
|
||||
t.Fatal("enabled 没映射过来")
|
||||
}
|
||||
if cfg.ProbeTimeout != 250*time.Millisecond || cfg.Cooldown != 90*time.Second {
|
||||
t.Fatalf("超时/冷却没映射: %+v", cfg)
|
||||
}
|
||||
if cfg.ScriptPath != ttslaunch.DefaultScriptPath || cfg.ProbeURL != ttslaunch.DefaultProbeURL {
|
||||
t.Fatalf("路径/地址没映射: %+v", cfg)
|
||||
}
|
||||
|
||||
// 空配置 ⇒ 不启用,且一个缺省值都不在这里填(Normalized 才是唯一裁决处)。
|
||||
if empty := voiceTTSLaunchConfig(runtimeconfig.VoiceTTSLaunchTuning{}); empty.Enabled {
|
||||
t.Fatal("空配置不该启用")
|
||||
} else if empty.ScriptPath != "" || empty.ProbeURL != "" || empty.ProbeTimeout != 0 {
|
||||
t.Fatalf("组合根不该替 launcher 填缺省值: %+v", empty)
|
||||
}
|
||||
}
|
||||
|
||||
// blackholeListener 起一个"只接连接、绝不回话"的回环监听:探活会一直等到自己的
|
||||
// 超时,正好用来证明挂载动作没有同步等它。
|
||||
func blackholeListener(t *testing.T) (net.Listener, string) {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
var conns []net.Conn
|
||||
var mu sync.Mutex
|
||||
go func() {
|
||||
for {
|
||||
c, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
conns = append(conns, c)
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
t.Cleanup(func() {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
for _, c := range conns {
|
||||
_ = c.Close()
|
||||
}
|
||||
})
|
||||
return ln, ln.Addr().String()
|
||||
}
|
||||
Reference in new issue
Block a user