feat(voice): M47 r1 探活地址单向回退到 voice-tts.json 的 baseUrl(组合根)
塔 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 一次都不被探
This commit is contained in:
1 parent
6906d274fc
commit
faf18afb91
2 files changed
+163
-1
No files matched your search
@@ -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 的配置。
|
||||
//
|
||||
// 它**不**在这里裁决缺省值(脚本路径/探活地址/超时/冷却的默认值全在
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user