// Package ttslaunch 在**后端已经起好之后**顺手照顾半云端的 emotionTTS: // 先探一次 `http://127.0.0.1:9880`,没在跑就异步拉起主人给的 // `D:\Project\Service\emotionTTS_v5.1.0\一键启动.bat`(切片 M47)。 // // 主人 2026-10-24 逐字:「tts启动脚本在这 ...\一键启动.bat,以后启动后端可以顺便 // 检测一下如果TTS没起就顺带启一下」。因此本包的三条口径是硬要求: // // 1. **只是便利,不进启动路径**:`Trigger` 把整段工作(探活 + 起进程)丢进后台 // goroutine 后**立即返回**,探活超时/起进程耗时都**不计入**后端启动时长。 // 2. **失败只 WARN**:探活超时、脚本不存在、起进程失败一律落到一条带 source 的 // 日志,`Trigger` 的返回值只说「这次有没有接单」,**没有 error 通道** —— 一个 // 没装 emotionTTS 的机器必须与改动前逐字一样地起得来。 // 3. **绝不重复拉起**:同进程内并发触发只会有一次探活/拉起(单飞),一次尝试之后 // 还有一段冷却,避免「后端连着重启」把 TTS 拉成好几个抢同一个端口的进程。 // // 默认**关**(主人早前:「开机自启先不急 后面做」):`Config.Enabled` 为 false 时 // 连探活都不发。开关与脚本路径走面板文件 `$DATA_DIR/config/voice-tts-launch.json` // (零新 `.env` 键,见 `internal/config/runtimeconfig`)。 package ttslaunch import ( "context" "errors" "log/slog" "strings" "sync" "time" ) const ( // DefaultProbeURL 是 emotionTTS 的默认探活地址(只绑回环,无鉴权)。 DefaultProbeURL = "http://127.0.0.1:9880" // DefaultScriptPath 是主人给的「一键启动.bat」默认路径。 DefaultScriptPath = `D:\Project\Service\emotionTTS_v5.1.0\一键启动.bat` // DefaultProbeTimeout 是单次探活超时:**毫秒级**。它只决定「等多久认定没起」, // 该值故意远小于任何"服务慢慢初始化"的量级 —— 判错的代价只是多拉一次脚本 // (已有实例会自己抢端口失败),而等太久的代价是每次都白等。 DefaultProbeTimeout = 300 * time.Millisecond // DefaultCooldown 是一次拉起尝试之后的冷却:这段时间内不再探、不再拉。 DefaultCooldown = 2 * time.Minute ) // Config 是这一级的行为旋钮。零值经 Normalized 变成"默认关 + 默认地址/脚本/超时"。 type Config struct { // Enabled 是总开关(默认 false)。关着时 Trigger 连探活都不会发。 Enabled bool // ScriptPath 是拉起用的启动脚本;空 = DefaultScriptPath。 ScriptPath string // ProbeURL 是探活地址;空 = DefaultProbeURL。 ProbeURL string // ProbeTimeout 是单次探活超时;零/负 = DefaultProbeTimeout。 ProbeTimeout time.Duration // Cooldown 是一次尝试之后的冷却;零/负 = DefaultCooldown。 Cooldown time.Duration } // Normalized 补齐缺省值(**唯一**的"缺省填什么"裁决处,调用方与配置层都不做)。 func (c Config) Normalized() Config { if strings.TrimSpace(c.ScriptPath) == "" { c.ScriptPath = DefaultScriptPath } if strings.TrimSpace(c.ProbeURL) == "" { c.ProbeURL = DefaultProbeURL } if c.ProbeTimeout <= 0 { c.ProbeTimeout = DefaultProbeTimeout } if c.Cooldown <= 0 { c.Cooldown = DefaultCooldown } return c } // Outcome 是一次同步检查(Check)的结论。它只用于日志与用例断言 —— 调用方(后端 // 启动序列)**不**需要它,也拿不到任何 error。 type Outcome int const ( // OutcomeDisabled 表示这一级没开(连探活都没发)。 OutcomeDisabled Outcome = iota // OutcomeAlreadyRunning 表示探活成功:服务已经在跑,什么都不做。 OutcomeAlreadyRunning // OutcomeLaunched 表示探活失败且启动脚本已被拉起。 OutcomeLaunched // OutcomeCooling 表示还在上一次尝试的冷却期内,这次跳过。 OutcomeCooling // OutcomeFailed 表示要拉但没拉起来(脚本不存在/起进程失败),已记 WARN。 OutcomeFailed // OutcomeCanceled 表示探活被**调用方的 ctx 取消**打断(典型是后端正在关闭): // 这不是"没起",而是"这次不看了" —— 跳过、不拉起、也不消耗冷却。 OutcomeCanceled ) // String 让日志与用例输出可读("outcome":"launched")。 func (o Outcome) String() string { switch o { case OutcomeDisabled: return "disabled" case OutcomeAlreadyRunning: return "already_running" case OutcomeLaunched: return "launched" case OutcomeCooling: return "cooling" case OutcomeFailed: return "failed" case OutcomeCanceled: return "canceled" default: return "unknown" } } // Launcher 是探活 + 拉起的那一个实例:进程内只该建一个(它自己就是去重器)。 // // 去重分两层,各自只管一件事: // - `inflight`(Trigger 里):同进程内并发触发只派**一个**后台 goroutine, // 后来者立即返回 false,连探活都不发。 // - `lastAttempt` + `checkMu`(Check 里):一次**尝试**(探活后确实去拉了)之后 // 记下时刻,冷却期内的再次检查直接 OutcomeCooling;`checkMu` 让两个直接调用 // Check 的路径也被串起来,避免双双越过探活各拉一次。 type Launcher struct { cfg Config log *slog.Logger probe probeFunc spawn spawnFunc now func() time.Time mu sync.Mutex inflight bool // lastAttempt 是上一次**拉起尝试**的时刻(零值 = 从未尝试过)。 lastAttempt time.Time checkMu sync.Mutex wg sync.WaitGroup } // probeFunc 探一次活:nil = 已在跑,非 nil = 没起(或探不动,同样按没起处理)。 type probeFunc func(ctx context.Context) error // spawnFunc 拉起启动脚本:nil = 已拉起,非 nil = 没拉起来。 type spawnFunc func(ctx context.Context) error // Option 是 New 的可选覆盖(日志、探活、起进程、时钟)。生产路径只用 WithLogger; // 其余三项存在的意义是让"已在跑 / 拉起失败 / 冷却 / 并发"这些分支能被用例钉住, // 而不必真的去起进程。 type Option func(*Launcher) // WithLogger 注入日志器(nil 会被换成丢弃型日志,避免 nil 解引用)。 func WithLogger(log *slog.Logger) Option { return func(l *Launcher) { if log != nil { l.log = log } } } // WithProbe 覆盖探活实现(默认用 HTTP GET 打 ProbeURL,任何 HTTP 回应都算在跑)。 func WithProbe(p probeFunc) Option { return func(l *Launcher) { if p != nil { l.probe = p } } } // WithSpawn 覆盖起进程实现(默认真起 ScriptPath 那个脚本)。 func WithSpawn(s spawnFunc) Option { return func(l *Launcher) { if s != nil { l.spawn = s } } } // WithClock 覆盖时钟(冷却的用例需要它,不必真等两分钟)。 func WithClock(now func() time.Time) Option { return func(l *Launcher) { if now != nil { l.now = now } } } // New 按 cfg 造一个 Launcher。cfg 在这里补齐缺省值(Config.Normalized)。 func New(cfg Config, opts ...Option) *Launcher { cfg = cfg.Normalized() l := &Launcher{ cfg: cfg, log: discardLogger(), now: time.Now, } l.probe = httpProbe(cfg.ProbeURL, cfg.ProbeTimeout) l.spawn = scriptSpawner(cfg.ScriptPath) for _, opt := range opts { if opt != nil { opt(l) } } // 🔴 探活地址是一道**闸**(复审 P2-2b):写成 `127.0.0.1:9880` 这种漏了 scheme // 的形状,`http.NewRequest` 每次都失败 ⇒「没起」永远成立 ⇒ **每次启动都拉一次 // 进程**(只有进程内冷却挡着)。宁可不启用,也不做这种自动重试机器。 // // 校验放在这里而不是只放组合根:`voice-tts.json` 的 `baseUrl` 兜底同样要过这道 // 闸,而那条路也是流进同一个 Config 的 —— 一处校验覆盖两个来源。 if err := ValidateProbeURL(l.cfg.ProbeURL); err != nil { l.log.Warn("emotionTTS auto-launch disabled: invalid probe url", "url", l.cfg.ProbeURL, "err", err) l.cfg.Enabled = false } return l } // Config 返回**补齐缺省值之后**的配置(接线处据此如实记一条日志)。 func (l *Launcher) Config() Config { if l == nil { return Config{} } return l.cfg } // Trigger 投递一次「探活 → 必要时拉起」,**立即返回**。 // // 返回值只回答「这一次有没有真的接单」(true = 派了后台任务;false = 没开 / 已在飞)。 // 它**没有** error 通道:拉起失败在后台记 WARN,绝不回流到启动路径。 func (l *Launcher) Trigger(ctx context.Context) bool { if l == nil || !l.cfg.Enabled { return false } if ctx == nil { ctx = context.Background() } if !l.begin() { return false } l.wg.Add(1) go func() { defer l.wg.Done() defer l.finish() l.Check(ctx) }() return true } // Check 同步做一次检查(Trigger 的后台体;也供诊断/用例直接调用)。 // // 它**不返回 error**:所有失败都是日志(WARN)与 Outcome 的一种。 func (l *Launcher) Check(ctx context.Context) Outcome { if l == nil || !l.cfg.Enabled { return OutcomeDisabled } if ctx == nil { ctx = context.Background() } l.checkMu.Lock() defer l.checkMu.Unlock() if l.cooling() { l.log.Debug("emotionTTS launch attempt in cooldown, skipping", "url", l.cfg.ProbeURL, "cooldown", l.cfg.Cooldown) return OutcomeCooling } probeErr := l.probe(ctx) if probeErr == nil { l.log.Debug("emotionTTS already listening", "url", l.cfg.ProbeURL) return OutcomeAlreadyRunning } // 🔴 **调用方取消 ≠ 没起**(复审 P2-2a):后端关闭时 ctx 被取消,探活随之返回 // `context.Canceled`;若把它当"没起",就会在**关闭过程中拉起 TTS**。这条必须是 // 跳过 —— 不记 markAttempt、不拉、也不 WARN 成故障(正常关闭不是故障)。 // // 判据只用 errors.Is,不按文案:探活**自身超时**是 context.DeadlineExceeded, // 那支仍然是"没起"(继续往下拉)。 if errors.Is(probeErr, context.Canceled) { l.log.Debug("emotionTTS probe canceled by caller, skipping launch", "url", l.cfg.ProbeURL, "err", probeErr) return OutcomeCanceled } l.log.Debug("emotionTTS not listening", "url", l.cfg.ProbeURL, "err", probeErr) // 先记时刻再起进程:两个检查若都走到这里,冷却已经把后来者挡住(配合 // checkMu,这一段的判定与记录是同一步)。 l.markAttempt() if err := l.spawn(ctx); err != nil { l.log.Warn("emotionTTS launch failed, backend startup unaffected", "script", l.cfg.ScriptPath, "err", err) return OutcomeFailed } l.log.Info("emotionTTS launch script started", "script", l.cfg.ScriptPath, "url", l.cfg.ProbeURL) return OutcomeLaunched } // Wait 等当前在飞的后台检查结束(关闭期/用例用;没有在飞任务时立即返回)。 func (l *Launcher) Wait() { if l == nil { return } l.wg.Wait() } // begin 抢到"这一次由我检查"的资格;已在飞则拒绝。 func (l *Launcher) begin() bool { l.mu.Lock() defer l.mu.Unlock() if l.inflight { return false } if !l.lastAttempt.IsZero() && l.now().Sub(l.lastAttempt) < l.cfg.Cooldown { return false } l.inflight = true return true } // finish 释放"在飞"标记(无论检查结果如何)。 func (l *Launcher) finish() { l.mu.Lock() l.inflight = false l.mu.Unlock() } // cooling 报告上一次拉起尝试是否还在冷却期内。 func (l *Launcher) cooling() bool { l.mu.Lock() defer l.mu.Unlock() return !l.lastAttempt.IsZero() && l.now().Sub(l.lastAttempt) < l.cfg.Cooldown } // markAttempt 记下一次拉起尝试的时刻(冷却的起点)。 func (l *Launcher) markAttempt() { l.mu.Lock() l.lastAttempt = l.now() l.mu.Unlock() }