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 返回**,绝不阻塞或改坏后端启动。 // 4. **探活地址单向回退**(塔 2026-10-26 裁决,见 resolveProbeURL):launch 文件 // 显式写了 `probeUrl` 就以它为准;没写则用 `voice-tts.json` 的 `baseUrl`;两者 // 都空才是默认 9880。反向(拿这里的地址去改合成面)一律不做。 // // 零新 `.env` 键(`.env` 已冻结):开关与脚本路径都走面板文件家族 // (`runtimeconfig`,与 `voice-tts.json` 同一套读取口径)。 import ( "context" "strings" "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 } resolved := a.resolveProbeURL(tuning.ProbeURL) if err := validateResolvedProbeURL(resolved); err != nil { // 🔴 写错地址(漏 scheme / 主机拼错)会让"没起"永远成立 ⇒ **每次启动都拉一次 // 进程**。宁可不启用,也不做这种自动重试机器(复审 P2-2b)。 log.Warn("emotionTTS auto-launch stays off: invalid probe url", "probe_url", resolved.url, "probe_url_source", resolved.probeSource, "err", err) return nil } launcher := ttslaunch.New(withProbeURL(voiceTTSLaunchConfig(tuning), resolved), ttslaunch.WithLogger(log)) cfg := launcher.Config() log.Info("emotionTTS auto-launch enabled", "probe_url", cfg.ProbeURL, // 探活地址可能来自三处,日志必须**同时**给出地址与来源:主人改端口后 // 一眼能看出实际问的是哪个地址、以及它是显式写的还是兜底来的。 "probe_url_source", resolved.probeSource, "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 } // probeResolution 是"探活地址 + 它的来源"(来源只用于日志,绝不参与判定)。 type probeResolution struct { url string probeSource string } // 探活地址的三个来源标签(写进启动 Info 行,供主人一眼核对)。 const ( probeSourceLaunchConfig = "voice-tts-launch.json" probeSourceVoiceTTS = "voice-tts.json" probeSourceDefault = "default" ) // resolveProbeURL 决定这次到底探哪个地址 —— **单向**回退,且只在组合根发生: // // 1. `voice-tts-launch.json` 显式写了 `probeUrl` ⇒ 用它(**永不**被别的键压过); // 2. 没写 ⇒ 兜底用 `voice-tts.json` 的 `baseUrl`(同一个服务,主人换端口只该改 // 一处;这正是"两处一起改"的 footgun 的收口); // 3. 两者都没写(或 voice-tts.json 坏了)⇒ 空串,由 `ttslaunch.Config.Normalized` // 补默认 `http://127.0.0.1:9880`。 // // 🔴 只做这一向:反向(拿 launch 的地址去改 `voice-tts.json` 的 `baseUrl`,或用它 // 顶替合成地址)一律不做 —— 合成面与"起没起"是两个问题,前者不能被后者的配置影响。 func (a *app) resolveProbeURL(explicit string) probeResolution { if url := strings.TrimSpace(explicit); url != "" { return probeResolution{url: url, probeSource: probeSourceLaunchConfig} } log := logger.WithSource(a.log, voiceTTSLaunchSource) tuning, err := runtimeconfig.LoadVoiceTTSTuning(runtimeconfig.DefaultVoiceTTSPath(resolveDataDir(a.cfg))) if err != nil { // 坏文件 ⇒ 这里只回落默认地址(**不**因此关掉自启:那是 launch 文件自己的 // 事),并且必须说清楚"我没读到 baseUrl"。 log.Warn("voice tts config unreadable, probe url falls back to default", "path", runtimeconfig.VoiceTTSConfigRelPath, "err", err) return probeResolution{probeSource: probeSourceDefault} } if base := strings.TrimSpace(tuning.BaseURL); base != "" { log.Debug("probe url inherited from voice tts config", "path", runtimeconfig.VoiceTTSConfigRelPath, "base_url", base) return probeResolution{url: base, probeSource: probeSourceVoiceTTS} } return probeResolution{probeSource: probeSourceDefault} } // withProbeURL 把解析出的探活地址填进 launcher 配置(空串留给 Normalized 补默认)。 func withProbeURL(cfg ttslaunch.Config, r probeResolution) ttslaunch.Config { cfg.ProbeURL = r.url return cfg } // validateResolvedProbeURL 校验**最终要探的那个地址**(空串 = 两个文件都没给,由 // Normalized 补默认 9880,默认值本身合法,这里不重复校验)。 // // 它和 `ttslaunch.New` 里的那道校验是同一把闸,但这里多做一件事:WARN 带上 // `probe_url_source` —— 主人一眼能看出该改哪个文件(launch 文件的 probeUrl, // 还是 voice-tts.json 的 baseUrl)。 func validateResolvedProbeURL(r probeResolution) error { if r.url == "" { return nil } return ttslaunch.ValidateProbeURL(r.url) } // 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, } }