From faf18afb91e89c190e1716a3f02f4985056c6dd6 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 26 Oct 2026 18:50:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(voice):=20M47=20r1=20=E6=8E=A2=E6=B4=BB?= =?UTF-8?q?=E5=9C=B0=E5=9D=80=E5=8D=95=E5=90=91=E5=9B=9E=E9=80=80=E5=88=B0?= =?UTF-8?q?=20voice-tts.json=20=E7=9A=84=20baseUrl=EF=BC=88=E7=BB=84?= =?UTF-8?q?=E5=90=88=E6=A0=B9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 塔 2026-10-26 裁决:主人若把合成端口从 9880 改掉,不该还要记得两处一起改。 回退只做一向,且只发生在组合根(包内不互相读配置): 1. voice-tts-launch.json 显式写了 probeUrl ⇒ 以它为准,**永不**被别的键压过; 2. 没写 ⇒ 用 voice-tts.json 的 baseUrl(同一个服务); 3. 两者都空(或 voice-tts.json 坏掉)⇒ 默认 http://127.0.0.1:9880。 启动 Info 行同时打印 probe_url 与 probe_url_source(voice-tts-launch.json / voice-tts.json / default),一眼看出用的是哪个源。反向(拿这里地址去改合成面) 一律不做。VoiceTTSTuning 坏掉时只回落默认地址并 WARN,**不**因此关掉自启。 新增三条用例(均已变异验证打红): - ProbeURLInheritedFromVoiceTTS:真让探活打到自定义端口(hits>0),摘掉回退即红 - ProbeURLFallsBackToDefault:两者都空 ⇒ 9880 + source=default - ExplicitProbeURLWins:显式压过 baseUrl,且 baseUrl 一次都不被探 --- cmd/cyrene/voice_tts_launch.go | 60 +++++++++++++++- cmd/cyrene/voice_tts_launch_test.go | 104 ++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+), 1 deletion(-) diff --git a/cmd/cyrene/voice_tts_launch.go b/cmd/cyrene/voice_tts_launch.go index 7ddfd8b..25abe1c 100644 --- a/cmd/cyrene/voice_tts_launch.go +++ b/cmd/cyrene/voice_tts_launch.go @@ -11,12 +11,16 @@ package main // 落不到后端的启动路径上。 // 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" @@ -46,10 +50,14 @@ func (a *app) startVoiceTTSLaunch(ctx context.Context) *ttslaunch.Launcher { "path", runtimeconfig.VoiceTTSLaunchConfigRelPath, "hint", `set {"enabled": true}`) return nil } - launcher := ttslaunch.New(voiceTTSLaunchConfig(tuning), ttslaunch.WithLogger(log)) + resolved := a.resolveProbeURL(tuning.ProbeURL) + 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), @@ -59,6 +67,56 @@ func (a *app) startVoiceTTSLaunch(ctx context.Context) *ttslaunch.Launcher { 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 +} + // voiceTTSLaunchConfig 把面板形状映射成 launcher 的配置。 // // 它**不**在这里裁决缺省值(脚本路径/探活地址/超时/冷却的默认值全在 diff --git a/cmd/cyrene/voice_tts_launch_test.go b/cmd/cyrene/voice_tts_launch_test.go index 7efaa6d..e979304 100644 --- a/cmd/cyrene/voice_tts_launch_test.go +++ b/cmd/cyrene/voice_tts_launch_test.go @@ -6,6 +6,9 @@ package main // 2. 启用后 `startVoiceTTSLaunch` **立即返回**,即使探活会挂住整整一个超时 —— // 证明 TTS 的延迟不计入后端启动时长(它是唯一"启动耗时"可测的量); // 3. 拉不起来(脚本不存在)⇒ 只有 WARN,**没有任何 error 回流**到启动路径。 +// 4. 探活地址**单向**回退(塔 2026-10-26 裁决):launch 文件显式写了 `probeUrl` +// 就以它为准;没写则用 `voice-tts.json` 的 `baseUrl`;两者都空才是默认 9880。 +// 启动 Info 行必须同时给出地址与来源。 // // 它用 `DefaultVoiceTTSLaunchPath` 的真实落点 // ($DATA_DIR/config/voice-tts-launch.json)跑,因此也钉住"零新 .env 键"。 @@ -16,10 +19,13 @@ import ( "fmt" "log/slog" "net" + "net/http" + "net/http/httptest" "os" "path/filepath" "strings" "sync" + "sync/atomic" "testing" "time" @@ -182,3 +188,101 @@ func blackholeListener(t *testing.T) (net.Listener, string) { }) return ln, ln.Addr().String() } + +// TestStartVoiceTTSLaunch_ProbeURLInheritedFromVoiceTTS 钉住**单向回退**的第一向: +// launch 文件没写 `probeUrl` 时,用 `voice-tts.json` 的 `baseUrl`(主人换端口只该 +// 改一处)。 +// +// 它不只比字符串 —— 真让探活打到那个端口(hits > 0):把回退摘掉,探活就会跑去 +// 默认 9880,本用例立刻红。 +func TestStartVoiceTTSLaunch_ProbeURLInheritedFromVoiceTTS(t *testing.T) { + dir := t.TempDir() + voiceTTS, hits := countingServer(t) + a, buf := launchTestApp(t, dir) + + // 合成面把端口改成了自定义端口;自启面**没写** probeUrl。 + writeVoiceTTSPanel(t, a, fmt.Sprintf(`{"enabled":true,"baseUrl":%q,"personas":{"cyrene":"char_1"}}`, voiceTTS.URL)) + writeLaunchPanel(t, dir, fmt.Sprintf(`{"enabled":true,"scriptPath":%q}`, + filepath.Join(dir, "不存在的一键启动.bat"))) + + launcher := a.startVoiceTTSLaunch(context.Background()) + if launcher == nil { + t.Fatal("启用后应拿到 launcher") + } + if got := launcher.Config().ProbeURL; got != voiceTTS.URL { + t.Fatalf("探活地址 = %q, want voice-tts.json 的 baseUrl %q", got, voiceTTS.URL) + } + launcher.Wait() + if n := atomic.LoadInt32(hits); n == 0 { + t.Fatal("探活没打到 voice-tts.json 的 baseUrl 端口(回退失效)") + } + if out := buf.String(); !strings.Contains(out, "probe_url_source=voice-tts.json") { + t.Fatalf("启动 Info 行没标出地址是兜底来的: %s", out) + } +} + +// TestStartVoiceTTSLaunch_ProbeURLFallsBackToDefault 钉住第二向:两个文件都没给 +// 地址 ⇒ 默认 `http://127.0.0.1:9880`,且日志标 `default`。 +func TestStartVoiceTTSLaunch_ProbeURLFallsBackToDefault(t *testing.T) { + dir := t.TempDir() + a, buf := launchTestApp(t, dir) + // 只有自启文件,没有 voice-tts.json。 + writeLaunchPanel(t, dir, fmt.Sprintf(`{"enabled":true,"scriptPath":%q}`, + filepath.Join(dir, "不存在的一键启动.bat"))) + + launcher := a.startVoiceTTSLaunch(context.Background()) + if launcher == nil { + t.Fatal("启用后应拿到 launcher") + } + if got := launcher.Config().ProbeURL; got != ttslaunch.DefaultProbeURL { + t.Fatalf("探活地址 = %q, want 默认 %q", got, ttslaunch.DefaultProbeURL) + } + launcher.Wait() + if out := buf.String(); !strings.Contains(out, "probe_url_source=default") { + t.Fatalf("启动 Info 行没标出地址用的是默认值: %s", out) + } +} + +// TestStartVoiceTTSLaunch_ExplicitProbeURLWins 钉住"显式压过兜底":launch 文件写了 +// `probeUrl` ⇒ 用它,**永不**去问 voice-tts.json 的 baseUrl(后者一次都不该被探)。 +func TestStartVoiceTTSLaunch_ExplicitProbeURLWins(t *testing.T) { + dir := t.TempDir() + explicit, explicitHits := countingServer(t) + inherited, inheritedHits := countingServer(t) + a, buf := launchTestApp(t, dir) + + writeVoiceTTSPanel(t, a, fmt.Sprintf(`{"enabled":true,"baseUrl":%q}`, inherited.URL)) + writeLaunchPanel(t, dir, fmt.Sprintf(`{"enabled":true,"probeUrl":%q,"scriptPath":%q}`, + explicit.URL, filepath.Join(dir, "不存在的一键启动.bat"))) + + launcher := a.startVoiceTTSLaunch(context.Background()) + if launcher == nil { + t.Fatal("启用后应拿到 launcher") + } + if got := launcher.Config().ProbeURL; got != explicit.URL { + t.Fatalf("探活地址 = %q, want 显式 %q", got, explicit.URL) + } + launcher.Wait() + if atomic.LoadInt32(explicitHits) == 0 { + t.Fatal("显式 probeUrl 没被用来探活") + } + if n := atomic.LoadInt32(inheritedHits); n != 0 { + t.Fatalf("显式 probeUrl 仍去问了 voice-tts.json 的地址 %d 次", n) + } + if out := buf.String(); !strings.Contains(out, "probe_url_source=voice-tts-launch.json") { + t.Fatalf("启动 Info 行没标出地址是显式配置的: %s", out) + } +} + +// countingServer 起一个"数请求次数"的 httptest 服务:用来证明探活**真的打到了** +// 某个地址,而不只是配置里写着那个字符串。 +func countingServer(t *testing.T) (*httptest.Server, *int32) { + t.Helper() + var hits int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + atomic.AddInt32(&hits, 1) + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(srv.Close) + return srv, &hits +}