From 3a2711a7e093b2c50a8471a135166af5be09c05f Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 26 Oct 2026 12:46:57 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat(voice):=20emotionTTS=20=E6=8E=A5?= =?UTF-8?q?=E6=88=90=E4=B8=80=E7=BA=A7=E5=8F=91=E9=9F=B3=E4=BA=BA=EF=BC=88?= =?UTF-8?q?UTF-8=20=E8=AF=B7=E6=B1=82=E4=BD=93=20+=20=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E5=9B=9E=E8=90=BD=E6=97=A2=E6=9C=89=E9=93=BE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 侦察结论(真机 + 上游源码): - POST /api/analyze_emotion ⇒ {primary,complex,intensity};primary 闭集 实测 平/喜/惊/哀/怒/厌/惧/急,intensity ∈ Low/Medium/High。 - POST /api/synthesize 收 emo_vector(float[])+emo_alpha,但还要 ref_audio_filename 且只回 {"audio_url"}(服务端落盘),不适合做运行时直出。 - POST /v1/audio/speech 内部先 LLM 分析情绪 + 匹配参考音,再把情绪向量以 [EMO:{vector}|{alpha}]base64:... 注入 voice 交给 indexTTS2 ⇒ **情绪确实影响 语气,但由服务端从待念文本自推**,我方没有可映射的参数。 - 上游把下游载荷的 speed 硬编码 1.0(core/utils/llm_provider.py),客户端 speed 是空转 ⇒ 本切片不暴露 speed,也不暴露 emotion/tone(不造假开关)。 实现: - internal/runtime/voice/tts:Client(唯一 HTTP 出口,json.Encoder 产出 UTF-8 请求体、超时/非 200/非音频/超限各自可分类)+ 字节嗅探庭裁 + Service(emotionTTS 在前、失败回落既有链,永不上抛成回复失败)+ Provider 适配(可作链的一级)。 - internal/config/runtimeconfig/voicetts.go:$DATA_DIR/config/voice-tts.json (enabled/baseUrl/character/format/timeoutSec),零新 .env 键。 - cmd/cyrene/wire_voice_tts.go + wireFrontend 的唯一注入点:两条既有出站口 (POST /api/v1/audio/speech 与 WS voice_output)同时用上;不新建端点/帧族。 - 严守 voice 入站闭集(不扩 Source/Status),出站音频不挂 voice DTO(塔裁决 B)。 测试:中文 UTF-8 请求体、非 200、超时、非音频、超限、未启用、空文本、嗅探表、 降级回落、未启用透传、两条出站口的字节级实测、面板配置解析、组合根映射; 三条变异(去中文 / 嗅探恒真 / 去掉回落)各自打红。真机 live 用例(build tag emotiontts_live)实测 278,036 字节 WAV。 --- cmd/cyrene/wire_frontend.go | 6 +- cmd/cyrene/wire_voice_tts.go | 84 ++++++ cmd/cyrene/wire_voice_tts_test.go | 102 +++++++ internal/config/runtimeconfig/voicetts.go | 116 ++++++++ .../config/runtimeconfig/voicetts_test.go | 73 +++++ internal/runtime/voice/tts/audio.go | 65 +++++ internal/runtime/voice/tts/audio_test.go | 35 +++ internal/runtime/voice/tts/client.go | 276 ++++++++++++++++++ internal/runtime/voice/tts/client_test.go | 230 +++++++++++++++ internal/runtime/voice/tts/config.go | 115 ++++++++ internal/runtime/voice/tts/live_test.go | 40 +++ internal/runtime/voice/tts/provider.go | 47 +++ internal/runtime/voice/tts/service.go | 161 ++++++++++ internal/runtime/voice/tts/service_test.go | 178 +++++++++++ .../services/gateway/voice_emotiontts_test.go | 145 +++++++++ 15 files changed, 1672 insertions(+), 1 deletion(-) create mode 100644 cmd/cyrene/wire_voice_tts.go create mode 100644 cmd/cyrene/wire_voice_tts_test.go create mode 100644 internal/config/runtimeconfig/voicetts.go create mode 100644 internal/config/runtimeconfig/voicetts_test.go create mode 100644 internal/runtime/voice/tts/audio.go create mode 100644 internal/runtime/voice/tts/audio_test.go create mode 100644 internal/runtime/voice/tts/client.go create mode 100644 internal/runtime/voice/tts/client_test.go create mode 100644 internal/runtime/voice/tts/config.go create mode 100644 internal/runtime/voice/tts/live_test.go create mode 100644 internal/runtime/voice/tts/provider.go create mode 100644 internal/runtime/voice/tts/service.go create mode 100644 internal/runtime/voice/tts/service_test.go create mode 100644 internal/services/gateway/voice_emotiontts_test.go diff --git a/cmd/cyrene/wire_frontend.go b/cmd/cyrene/wire_frontend.go index ed1da9a..91a77ed 100644 --- a/cmd/cyrene/wire_frontend.go +++ b/cmd/cyrene/wire_frontend.go @@ -126,7 +126,11 @@ func (a *app) wireFrontend() error { if err != nil { return fmt.Errorf("gateway: %w", err) } - gw.SetVoice(a.voice) + // 语音面只在这**一处**注入:emotionTTS 已按面板配置启用时是「emotionTTS + + // 既有链」的门面(失败回落既有链),否则就是既有链本身(行为逐字不变)。 + // 两条既有出站口(POST /api/v1/audio/speech 与 WS voice_output)都读这一份, + // 因此不新建端点、不加帧族(切片 M35 红线)。 + gw.SetVoice(a.voiceForGateway()) a.gw = gw // 必须在下面接线 hook 之前赋值——那些 hook 捕获了 a.gw / gw(否则会 nil 接收者 panic) // 可观测性 trace 出口(design/19 §2):网关已存在,其环形缓冲必须由循环产出的 // span 喂养,否则管理端 trace/metrics 接口会一直是空的。 diff --git a/cmd/cyrene/wire_voice_tts.go b/cmd/cyrene/wire_voice_tts.go new file mode 100644 index 0000000..b282288 --- /dev/null +++ b/cmd/cyrene/wire_voice_tts.go @@ -0,0 +1,84 @@ +package main + +// 本文件把**半云端 emotionTTS** 接成她的一级发音人(切片 M35),并保持三条红线: +// +// 1. **配置走面板文件** `$DATA_DIR/config/voice-tts.json`(零新 `.env` 键,design/13 +// §2.1)。文件缺失/坏掉 ⇒ 这一级**不启用**,既有语音链逐字不变。 +// 2. **文本优先**:合成失败只回落既有链 + 一条带来源的 WARN,绝不把"这条回复" +// 判成失败(聊天正文早已发出/落库)。 +// 3. **不新建端点/帧族**:只换网关 `SetVoice` 的注入点,因此既有两条出站口 +// (`POST /api/v1/audio/speech` 与 WS `voice_output`)同时用上它。 +// +// 为什么包一层而不是插进链里:既有链由 `multimodal/voice.New` 按 env 组装,各级 +// 列表不外露;`.env` 已冻结,不能为新能力加键。包装既不动那条链,也让"失败回落" +// 发生在**合成入口**这一处,而不是散在两条出站口里。 + +import ( + "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/multimodal/voice" + voicetts "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/tts" + "git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway" +) + +// voiceTTSSource 是本切片日志的来源标签(AGENTS §10:日志必须标来源)。 +const voiceTTSSource = "cmd/cyrene/voice-tts" + +// voiceForGateway 返回注入给网关的语音服务:emotionTTS 已按面板配置启用时是 +// 「emotionTTS + 既有链」的门面,否则**就是既有链本身**(行为逐字不变)。 +// +// 它只在 `wireFrontend` 接线时调用一次;每次调用都重新读一次面板文件(启动期 +// 读一次的口径与 `backthink.json` 一致)。 +func (a *app) voiceForGateway() gateway.VoiceService { + if a.voice == nil { + return nil + } + cfg, enabled := a.voiceTTSConfig() + if !enabled { + return a.voice + } + client := voicetts.New(cfg, voicetts.WithLogger(logger.WithSource(a.log, voiceTTSSource))) + service := voicetts.NewService(client, a.voice, voicetts.WithServiceLogger(logger.WithSource(a.log, voiceTTSSource))) + logger.WithSource(a.log, voiceTTSSource).Info("emotionTTS voice enabled", + "base_url", client.RedactedBaseURL(), + "character", client.Character(), + "format", cfg.Format, + "timeout_sec", int(cfg.Timeout/time.Second), + "config", runtimeconfig.VoiceTTSConfigRelPath) + return service +} + +// voiceTTSConfig 读面板配置并映射成 provider 配置。第二个返回值报告"这一级是否 +// 启用"——坏文件、缺文件、显式 false 都得出 false(并在坏文件时留一条 WARN)。 +// +// 它**不**在这里做"缺省填什么"的裁决:默认值在 `voicetts.Config.Normalized` 一处。 +func (a *app) voiceTTSConfig() (voicetts.Config, bool) { + log := logger.WithSource(a.log, voiceTTSSource) + path := runtimeconfig.DefaultVoiceTTSPath(resolveDataDir(a.cfg)) + tuning, err := runtimeconfig.LoadVoiceTTSTuning(path) + if err != nil { + // 坏文件 ⇒ **这一级不启用**:宁可没有它,也不要用一份读不懂的配置去驱动 + // 一台外部服务。既有链照旧服务(文本优先)。 + log.Warn("voice tts config unreadable, emotionTTS stays disabled", + "path", runtimeconfig.VoiceTTSConfigRelPath, "err", err) + return voicetts.Config{}, false + } + if !tuning.EnabledOn() { + log.Debug("emotionTTS not enabled in panel config", + "path", runtimeconfig.VoiceTTSConfigRelPath, "hint", "set {\"enabled\": true}") + return voicetts.Config{}, false + } + return voicetts.Config{ + Enabled: true, + BaseURL: tuning.BaseURL, + Character: tuning.Character, + Format: tuning.Format, + Timeout: time.Duration(tuning.TimeoutSec) * time.Second, + }, true +} + +// voiceFallbackChain 声明 a.voice 满足 emotionTTS 门面要的底链面(编译期断言)。 +// 它把"既有链少了一个方法"这种改动的代价,从**运行期 panic** 提前到**编译失败**。 +var _ voicetts.Chain = (*voice.Chain)(nil) diff --git a/cmd/cyrene/wire_voice_tts_test.go b/cmd/cyrene/wire_voice_tts_test.go new file mode 100644 index 0000000..77115c3 --- /dev/null +++ b/cmd/cyrene/wire_voice_tts_test.go @@ -0,0 +1,102 @@ +package main + +// 本文件锁定切片 M35 在**组合根**上的三条口径(都是"改坏就静默不工作/静默开错"的那种): +// +// 1. 面板文件缺失 ⇒ **这一级不启用**,返回的仍是既有链本身(行为逐字不变); +// 2. 文件里 `enabled:true` ⇒ 返回 emotionTTS 门面,角色/格式/超时按文件来; +// 3. 文件坏掉 ⇒ **不启用**(宁可没有这一级,也不拿读不懂的配置去驱动外部服务)。 +// +// 它用 `DefaultVoiceTTSPath` 的真实落点($DATA_DIR/config/voice-tts.json)跑, +// 因此也钉住"零新 .env 键":没有任何一处读环境变量。 + +import ( + "os" + "path/filepath" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/config" + mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice" + voicetts "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/tts" +) + +// voiceTTSApp 造一个只够本题用的 app:数据目录 + 一条既有链。 +func voiceTTSApp(t *testing.T) *app { + t.Helper() + dir := t.TempDir() + return &app{ + cfg: &config.Config{DataDir: dir}, + log: tidyTestLog(), + voice: mvoice.New(config.VoiceConfig{}), + } +} + +// TestVoiceForGateway_DisabledByDefault 钉住"没配过 ⇒ 原样用既有链"。 +func TestVoiceForGateway_DisabledByDefault(t *testing.T) { + a := voiceTTSApp(t) + got := a.voiceForGateway() + if got != any(a.voice) { + t.Fatalf("没配过时不该包装既有链: %T", got) + } +} + +// TestVoiceForGateway_WrapsWhenEnabled 钉住"文件启用 ⇒ 门面生效 + 配置逐项读对"。 +func TestVoiceForGateway_WrapsWhenEnabled(t *testing.T) { + a := voiceTTSApp(t) + writeVoiceTTSPanel(t, a, `{"enabled":true,"baseUrl":"http://127.0.0.1:9880", + "character":"Cyrene","format":"wav","timeoutSec":45}`) + + got := a.voiceForGateway() + svc, ok := got.(*voicetts.Service) + if !ok { + t.Fatalf("启用后应返回 *tts.Service, got %T", got) + } + if !svc.PrimaryActive() { + t.Fatal("面板写了 enabled:true,但这一级没启用") + } + if svc.Character() != "Cyrene" { + t.Errorf("character = %q, want Cyrene(面板值)", svc.Character()) + } +} + +// TestVoiceForGateway_BadFileStaysDisabled 钉住"坏文件 ⇒ 不启用"(绝不半开)。 +func TestVoiceForGateway_BadFileStaysDisabled(t *testing.T) { + a := voiceTTSApp(t) + writeVoiceTTSPanel(t, a, `{"enabled":`) + + got := a.voiceForGateway() + if _, ok := got.(*voicetts.Service); ok { + t.Fatal("坏文件却启用了 emotionTTS(会拿读不懂的配置去调外部服务)") + } + if got != any(a.voice) { + t.Fatalf("坏文件时应原样用既有链, got %T", got) + } +} + +// TestVoiceTTSConfig_NoEnvKeys 钉住"零新 .env 键":把相关环境变量设成垃圾值, +// 配置解析的结果只由面板文件决定(证明它根本没读环境)。 +func TestVoiceTTSConfig_NoEnvKeys(t *testing.T) { + a := voiceTTSApp(t) + t.Setenv("VOICE_TTS_PROVIDER", "emotiontts") + t.Setenv("EMOTIONTTS_BASE_URL", "http://evil.invalid") + writeVoiceTTSPanel(t, a, `{"enabled":true}`) + + cfg, enabled := a.voiceTTSConfig() + if !enabled { + t.Fatal("面板启用却没启用") + } + if cfg.BaseURL != "" { + t.Errorf("baseURL = %q,want 空(默认值由 Normalized 补,不读环境)", cfg.BaseURL) + } +} + +// writeVoiceTTSPanel 把内容写进**真实落点**($DATA_DIR/config/voice-tts.json)。 +func writeVoiceTTSPanel(t *testing.T, a *app, body string) { + t.Helper() + path := filepath.Join(a.cfg.DataDir, "config", "voice-tts.json") + 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) + } +} diff --git a/internal/config/runtimeconfig/voicetts.go b/internal/config/runtimeconfig/voicetts.go new file mode 100644 index 0000000..fdd0890 --- /dev/null +++ b/internal/config/runtimeconfig/voicetts.go @@ -0,0 +1,116 @@ +// voicetts.go 是 **emotionTTS 语音合成**(切片 M35)的可变源: +// `$DATA_DIR/config/voice-tts.json`(design/13 §2.1「运行期生成的配置进 +// `$DATA_DIR/config/`;调优/门槛/上限/开关类参数走这里 + 代码默认值」)。 +// +// 为什么需要它:把半云端的 emotionTTS 接成她的一级发音人,需要三个运维旋钮 —— +// 开关、地址、角色;而 `.env` 已被主人冻结(**零新 env 键**,design/13 §2.1), +// 所以它们落在这里,与 `memory-tidy.json` / `backthink.json` 同一家族口径。 +// +// 🔴 **刻意没有 speed / emotion / tone 三键**(塔 2026-10-26 裁决,实测证据): +// - 上游把下游载荷的 `speed` **硬编码 1.0**(`core/utils/llm_provider.py` 的 +// `synthesize_audio`),客户端传的 speed 是空转 ⇒ 做一个键就是做一个**假开关**; +// - 情绪由上游**自己**推(LLM 分析待念文本 + 匹配参考音 + 注入情绪向量),我方 +// 没有可映射的参数 ⇒ 不做"情绪 → 语气"映射表(有映射表也只是自嗨)。 +// +// 边界(与 backthink.go / memorytidy.go 同源的手续): +// +// 1. 本文件只管**文件形状 + 读取**:它不认识 `tts.Config`,也不做"缺省填什么值" +// 的裁决(那是 `tts.Config.Normalized` 的事)。 +// 2. **没有"面板编辑"的写口**:改内容仍要**人手改文件 + 重启**(与 +// `backthink.json` 同口径:启动期读一次)。本切片不新增播种文件 —— 文件不存在 +// 就是"没配过",该级不启用。 +// 3. **零新 env 键**,也不进面板的配置事实卡(那张表的键名被 `.env.example` 钉住)。 +// +// 读取口径:文件缺失或全空白 = 还没配过(零值 + nil,**不是错误**);存在但解不开 +// = 返回错误,调用方记 WARN 并**回落到"不启用"**(一个坏文件绝不能把语音面变成 +// 「半开」状态 —— 宁可没有这一级,也不要用一份读不懂的配置去调外部服务)。 +package runtimeconfig + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +const ( + // voiceTTSFileName 是文件名(家族命名 = 服务/模块名 + .json)。 + voiceTTSFileName = "voice-tts.json" + // voiceTTSConfigSubdir 是落点的第一段目录(`$DATA_DIR/config/`)。与其它族 + // 各自声明一份,避免某一族换位置时另一处静默跟着搬家。 + voiceTTSConfigSubdir = "config" +) + +// VoiceTTSConfigRelPath 是落点相对 `$DATA_DIR` 的路径(`config/voice-tts.json`)。 +// 导出它是为了让面板/文档能**如实**告诉主人改哪个文件。 +const VoiceTTSConfigRelPath = voiceTTSConfigSubdir + "/" + voiceTTSFileName + +// ErrInvalidVoiceTTS 是 voice-tts.json 结构不合法时的分类错误(errors.Is 可判类)。 +var ErrInvalidVoiceTTS = errors.New("runtimeconfig: invalid voice tts config") + +// VoiceTTSTuning 是 `voice-tts.json` 的形状:emotionTTS 那个发音人的运维旋钮。 +// +// 字段名是 camelCase 的 JSON 键(文件是给人看、给人改的)。 +type VoiceTTSTuning struct { + // Enabled 控制这一级是否参与合成。nil = 没配(**不启用**);true = 启用; + // false = 显式关掉(与"没配"同效,写它只是为了自解释)。 + // + // 用**指针**是刻意的:`bool` 的零值 false 与"键没写"不可区分,而这一级的默认 + // 语义是**关**(它要连一台默认在本机回环上的外部服务,不能靠"猜"打开)。 + // 这里指针带来的是"没写"与"写了 false"在日志里可区分,行为上二者都等于不启用。 + Enabled *bool `json:"enabled"` + // BaseURL 是服务地址。空 = 代码默认 `http://127.0.0.1:9880`。 + // + // 🔴 该服务**无鉴权、只绑回环**:这里填公网地址等于把一个无鉴权的合成口暴露给 + // 网络,安全上由部署方自行负责(本文件不替人做这个决定,也不给默认值以外的暗示)。 + BaseURL string `json:"baseUrl,omitempty"` + // Character 是角色(服务端 `/api/characters` 的 id 或名字)。 + // 空 = 代码默认 `char_84fb88a3`(主人指定的 Cyrene)。 + Character string `json:"character,omitempty"` + // Format 是请求格式,只接受 wav/mp3/ogg。空 = 代码默认 wav。 + // + // 注意:上游产物**恒为 WAV**(它按 `response_format` 自报 media_type,但字节是 + // WAV)⇒ 真正下发的格式一律由字节嗅探裁决,这一键只影响请求参数。 + Format string `json:"format,omitempty"` + // TimeoutSec 是单次合成超时(**秒**)。零/负 = 没配 ⇒ 代码默认 60 秒。 + // + // 用秒而不是 Go 时长字面量:这个文件是给人改的,写 `60` 比要求记住 `"60s"` + // 更不容易写错,也与既有 `$DATA_DIR/config/**` 的 `*_SEC` 口径一致。 + TimeoutSec int `json:"timeoutSec,omitempty"` +} + +// EnabledOn 报告这一级是否被显式打开(nil = 没配 ⇒ 不启用)。 +func (t VoiceTTSTuning) EnabledOn() bool { return t.Enabled != nil && *t.Enabled } + +// DefaultVoiceTTSPath 返回落点:`$DATA_DIR/config/voice-tts.json`。 +func DefaultVoiceTTSPath(dataDir string) string { + return filepath.Join(dataDir, voiceTTSConfigSubdir, voiceTTSFileName) +} + +// LoadVoiceTTSTuning 读取 voice-tts.json(**只读**)。 +// +// 文件缺失或全空白 ⇒ 零值 + nil:调用方据此得出"这一级没配过(不启用)", +// **不是**错误 —— 一台没装 emotionTTS 的机器不该每次启动都刷 WARN。 +// +// 存在但解不开 ⇒ 返回包装了 ErrInvalidVoiceTTS 的错误。调用方应记一条带 source 的 +// WARN 并**回落"不启用"**(见文件头第 3 条);**绝不得覆盖**那个文件(损坏的文件 +// 可以人工救回,程序盖掉它就丢了人工写的意图)。 +func LoadVoiceTTSTuning(path string) (VoiceTTSTuning, error) { + raw, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return VoiceTTSTuning{}, nil + } + return VoiceTTSTuning{}, fmt.Errorf("runtimeconfig: read %s: %w", path, err) + } + if strings.TrimSpace(string(raw)) == "" { + return VoiceTTSTuning{}, nil + } + var t VoiceTTSTuning + if err := json.Unmarshal(raw, &t); err != nil { + return VoiceTTSTuning{}, fmt.Errorf("%w: decode %s: %w", ErrInvalidVoiceTTS, path, err) + } + return t, nil +} diff --git a/internal/config/runtimeconfig/voicetts_test.go b/internal/config/runtimeconfig/voicetts_test.go new file mode 100644 index 0000000..a014f72 --- /dev/null +++ b/internal/config/runtimeconfig/voicetts_test.go @@ -0,0 +1,73 @@ +package runtimeconfig + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +// TestLoadVoiceTTSTuning 锁定 voice-tts.json 的四条读取口径: +// 缺失 = 没配过(不是错误)、全空白 = 没配过、合法 = 逐键读出、坏文件 = 可分类错误。 +func TestLoadVoiceTTSTuning(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "voice-tts.json") + + // ① 缺失 + if tuning, err := LoadVoiceTTSTuning(path); err != nil || tuning.EnabledOn() { + t.Fatalf("缺失: tuning=%+v err=%v, want 零值 + nil", tuning, err) + } + + // ② 全空白 + if err := os.WriteFile(path, []byte(" \n\t "), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + if tuning, err := LoadVoiceTTSTuning(path); err != nil || tuning.EnabledOn() { + t.Fatalf("空白: tuning=%+v err=%v, want 零值 + nil", tuning, err) + } + + // ③ 合法 + body := `{"enabled":true,"baseUrl":"http://127.0.0.1:9880","character":"char_84fb88a3", + "format":"wav","timeoutSec":45}` + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + tuning, err := LoadVoiceTTSTuning(path) + if err != nil { + t.Fatalf("合法文件: %v", err) + } + if !tuning.EnabledOn() || tuning.Character != "char_84fb88a3" || tuning.Format != "wav" || tuning.TimeoutSec != 45 { + t.Fatalf("读取结果不对: %+v", tuning) + } + + // ④ 显式 false 与"没配"同效,但指针让它在读取侧可区分。 + if err := os.WriteFile(path, []byte(`{"enabled":false}`), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + if tuning, err = LoadVoiceTTSTuning(path); err != nil || tuning.EnabledOn() || tuning.Enabled == nil { + t.Fatalf("显式 false: tuning=%+v err=%v", tuning, err) + } + + // ⑤ 坏文件 ⇒ 可分类错误(调用方据此回落"不启用",且绝不覆盖原文件) + if err := os.WriteFile(path, []byte(`{"enabled":`), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + if _, err := LoadVoiceTTSTuning(path); !errors.Is(err, ErrInvalidVoiceTTS) { + t.Fatalf("坏文件 err = %v, want ErrInvalidVoiceTTS", err) + } + if raw, err := os.ReadFile(path); err != nil || string(raw) != `{"enabled":` { + t.Fatalf("坏文件被覆盖了: %q err=%v", string(raw), err) + } +} + +// TestDefaultVoiceTTSPath 钉住落点:`$DATA_DIR/config/voice-tts.json`(零新 .env 键)。 +func TestDefaultVoiceTTSPath(t *testing.T) { + got := DefaultVoiceTTSPath(filepath.Join("data", "root")) + want := filepath.Join("data", "root", "config", "voice-tts.json") + if got != want { + t.Fatalf("path = %q, want %q", got, want) + } + if VoiceTTSConfigRelPath != "config/voice-tts.json" { + t.Fatalf("RelPath = %q", VoiceTTSConfigRelPath) + } +} diff --git a/internal/runtime/voice/tts/audio.go b/internal/runtime/voice/tts/audio.go new file mode 100644 index 0000000..8c241a1 --- /dev/null +++ b/internal/runtime/voice/tts/audio.go @@ -0,0 +1,65 @@ +package tts + +// 本文件是**音频字节嗅探**:判断上游回给我们的到底是不是音频,以及它到底是什么 +// 格式。 +// +// 两条口径(与本仓「字节嗅探是唯一裁决权」同源,design/24 §10.1/§10.11): +// - **不信声明**:上游的 `Content-Type` 与我自己发出去的 `response_format` 都不 +// 是判据 —— 服务端产物恒为 WAV,但会按 `response_format` 自报 media_type +// (`tts_router.py` 的 `FileResponse`),照抄它就会把 WAV 标成 mp3。 +// - **不是音频就报错**:上游 200 也可能是一段 JSON/HTML(配置错、反代错误页), +// 把它当音频落盘/下发等于给客户端一个打不开的文件。判不出来就 **ErrNotAudio**, +// 由调用方回落既有链 —— 绝不"拿到字节就算成功"。 + +// sniffAudio 按魔数判断 b 是不是可下发的音频,返回格式名与是否认出。 +// +// 认得的容器覆盖本仓音频白名单里最常见的那几种(wav / mp3 / ogg / flac / +// m4a(mp4) / webm / amr);认不出返回 ("", false)。 +func sniffAudio(b []byte) (string, bool) { + switch { + case hasPrefix(b, "RIFF") && len(b) >= 12 && string(b[8:12]) == "WAVE": + return "wav", true + case hasPrefix(b, "ID3"): + return "mp3", true + case isMP3Frame(b): + return "mp3", true + case hasPrefix(b, "OggS"): + return "ogg", true + case hasPrefix(b, "fLaC"): + return "flac", true + case len(b) >= 12 && string(b[4:8]) == "ftyp": + return "m4a", true + case hasPrefix(b, "\x1a\x45\xdf\xa3"): + return "webm", true + case hasPrefix(b, "#!AMR"): + return "amr", true + default: + return "", false + } +} + +// isMP3Frame 识别无 ID3 头的裸 MPEG 音频帧同步字(11 位全 1 + 合法版本/层)。 +// 只做**粗判**:音频帧的头两字节 + 一个合法比特率/采样率索引即可,目的是把 +// "看起来像二进制"与"真的是 MP3"区分开,不追求完整解析。 +func isMP3Frame(b []byte) bool { + if len(b) < 3 { + return false + } + if b[0] != 0xFF || b[1]&0xE0 != 0xE0 { + return false + } + // 版本位(b[1] 的 bit 3-4)不能是 01(保留值);层位(bit 1-2)不能是 00。 + if b[1]&0x18 == 0x08 || b[1]&0x06 == 0x00 { + return false + } + // 比特率索引(b[2] 的 bit 4-7)不能是 1111(非法)。 + return b[2]>>4 != 0x0F +} + +// hasPrefix 报告 b 是否以 s 开头(s 为 ASCII 字符串字面量)。 +func hasPrefix(b []byte, s string) bool { + if len(b) < len(s) { + return false + } + return string(b[:len(s)]) == s +} diff --git a/internal/runtime/voice/tts/audio_test.go b/internal/runtime/voice/tts/audio_test.go new file mode 100644 index 0000000..3ed3435 --- /dev/null +++ b/internal/runtime/voice/tts/audio_test.go @@ -0,0 +1,35 @@ +package tts + +import "testing" + +// TestSniffAudio 锁定字节嗅探的判据表:认得的容器各自给出正确的格式名, +// 「不是音频」一律 ("" , false) —— 这一条是"绝不把 JSON/HTML 当音频"的地基。 +func TestSniffAudio(t *testing.T) { + cases := []struct { + name string + data []byte + format string + ok bool + }{ + {"wav", wavBytes("x"), "wav", true}, + {"mp3-id3", append([]byte("ID3"), 0, 0, 0, 0, 0, 0), "mp3", true}, + {"mp3-frame", []byte{0xFF, 0xFB, 0x90, 0x00}, "mp3", true}, + {"ogg", []byte("OggS\x00\x02"), "ogg", true}, + {"flac", []byte("fLaC\x00\x00\x00\x22"), "flac", true}, + {"m4a", []byte{0, 0, 0, 0x20, 'f', 't', 'y', 'p', 'M', '4', 'A', ' '}, "m4a", true}, + {"webm", []byte{0x1A, 0x45, 0xDF, 0xA3, 0x01}, "webm", true}, + {"amr", []byte("#!AMR\n"), "amr", true}, + {"json", []byte(`{"detail":"nope"}`), "", false}, + {"html", []byte("502"), "", false}, + {"empty", nil, "", false}, + {"riff-not-wave", append([]byte("RIFF"), []byte("\x00\x00\x00\x00AVI ")...), "", false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + format, ok := sniffAudio(c.data) + if ok != c.ok || format != c.format { + t.Fatalf("sniffAudio = (%q,%v), want (%q,%v)", format, ok, c.format, c.ok) + } + }) + } +} diff --git a/internal/runtime/voice/tts/client.go b/internal/runtime/voice/tts/client.go new file mode 100644 index 0000000..79a55d4 --- /dev/null +++ b/internal/runtime/voice/tts/client.go @@ -0,0 +1,276 @@ +package tts + +// 本文件是 emotionTTS 的 **HTTP 实现**:一次合成 = 一个 +// `POST /v1/audio/speech`,回的是**原始音频字节**(不是 JSON 信封)。 +// +// 🔴🔴 **请求体必须是 UTF-8 JSON 字节**(本包最重要的一条约束) +// +// 该服务用 FastAPI/Pydantic 解 `application/json`;请求体里若出现**非 UTF-8** 的 +// 中文字节,它会直接回 +// `400 {"detail":"There was an error parsing the body"}`(塔已实测:Git Bash 内联 +// 中文走 GBK 送过去就是这个错)。因此本文件**只**用 `encoding/json` 编码请求体 +// (`json.Encoder` 天然产出 UTF-8 字节,且关掉 HTML 转义以免正文被改写)。 +// +// 以下写法**一律禁止**,改一处就会把中文打回 400: +// - `fmt.Sprintf` / 字符串拼接拼 JSON 体; +// - 把正文按 GBK/GB18030 转码后再发; +// - 用 `text/template` 之类产出请求体。 +// +// `client_test.go` 里有一条**中文文本**的 httptest 用例专门钉这条:它既断言请求体 +// 能被 UTF-8 解码、正文逐字相等,也断言字节里能直接搜到那段中文的 UTF-8 编码。 + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "strings" + "sync/atomic" +) + +// 本包的哨兵错误。每个都以 %w 包装,调用方既能按类别判断,也拿得到原始原因。 +var ( + // ErrDisabled 报告这一级**未启用**(面板开关关着)。它不是故障:调用方应保持 + // 既有链路不变,而不是把它当成"合成失败"去告警。 + ErrDisabled = errors.New("voice/tts: emotionTTS provider is disabled") + // ErrEmptyText 报告要合成的文本为空(没有声音可合成,不必打网络)。 + ErrEmptyText = errors.New("voice/tts: empty text") + // ErrUnreachable 报告**没拿到响应**:连接失败、超时、被取消。它与"上游拒了" + // 刻意分开 —— 前者是"它没听见",后者是"它听见了但不同意",排查路径不同。 + ErrUnreachable = errors.New("voice/tts: emotionTTS unreachable") + // ErrUpstream 报告上游回了**非 200**(4xx/5xx)。错误里带状态码,但**不带正文** + // (正文是她的回复,日志与错误链里都不该出现)。 + ErrUpstream = errors.New("voice/tts: emotionTTS rejected the request") + // ErrNotAudio 报告上游回了 200,但字节**认不出是音频**(多为 JSON/HTML 错误页)。 + // 它是"拿到的不是我们要的东西",绝不能当成功落盘或下发。 + ErrNotAudio = errors.New("voice/tts: upstream response is not audio") + // ErrTooLarge 报告响应超过体积上限(见 defaultMaxAudioBytes)。 + ErrTooLarge = errors.New("voice/tts: upstream response too large") +) + +// speechPayload 是 `/v1/audio/speech` 的请求体(OpenAITTSRequest 的子集, +// 字段名逐字照上游 openapi.json)。 +// +// 🔴 **没有 `speed`**:上游把下游载荷的 speed 硬编码成 1.0 +// (`core/utils/llm_provider.py` 的 `synthesize_audio`),传了也是空转,因此不暴露 +// 假开关(见包注释)。也没有情绪/语气字段 —— 上游自己从 `input` 推。 +type speechPayload struct { + // Model 是固定模型名(上游只认 emotionTTS)。 + Model string `json:"model"` + // Input 是要合成的文本(UTF-8;见文件头的红线)。 + Input string `json:"input"` + // Voice 是角色 id 或名字(`char_84fb88a3` / `Cyrene` 都认)。 + Voice string `json:"voice"` + // ResponseFormat 是请求格式;产物恒为 WAV,真实格式按字节嗅探。 + ResponseFormat string `json:"response_format"` +} + +// Stats 是一次累计的合成计数(进程内单调递增,用于面板/日志观测"降级了多少次")。 +type Stats struct { + // Attempts 是发起过的合成次数。 + Attempts int64 + // Failures 是失败次数(任何非 nil 错误的合成)。 + Failures int64 +} + +// Client 是 emotionTTS 的合成客户端。零值不可用,请用 New 构造。 +type Client struct { + cfg Config + http *http.Client + log *slog.Logger + maxLen int64 + + attempts atomic.Int64 + failures atomic.Int64 +} + +// Option 定制 Client 的 HTTP 客户端、日志器与响应体积上限(测试用)。 +type Option func(*Client) + +// WithLogger 注入带来源的日志器(既有 WARN 都带上它)。 +func WithLogger(l *slog.Logger) Option { return func(c *Client) { c.log = l } } + +// WithHTTPClient 注入 HTTP 客户端(测试用短超时/自定义 Transport)。 +// 注入后 `Config.Timeout` 不再生效 —— 超时由注入的客户端自己负责。 +func WithHTTPClient(h *http.Client) Option { return func(c *Client) { c.http = h } } + +// WithMaxResponseBytes 覆盖响应体积上限(测试用)。 +func WithMaxResponseBytes(n int64) Option { return func(c *Client) { c.maxLen = n } } + +// New 构造一个 emotionTTS 客户端。配置里的默认值由 `Config.Normalized` 补齐; +// `Enabled=false` 时客户端仍然可用,但每次合成都会立刻回 `ErrDisabled`(这样 +// "关掉开关"与"它挂了"在调用方那里是两种可区分的情形)。 +func New(cfg Config, opts ...Option) *Client { + c := &Client{log: slog.Default(), maxLen: defaultMaxAudioBytes} + for _, o := range opts { + o(c) + } + c.cfg = cfg.Normalized(c.log) + if c.http == nil { + c.http = &http.Client{Timeout: c.cfg.Timeout} + } + return c +} + +// Config 返回**归一化之后**的实际生效配置(面板/日志据此如实展示)。 +func (c *Client) Config() Config { return c.cfg } + +// Enabled 报告这一级是否启用。 +func (c *Client) Enabled() bool { return c.cfg.Enabled } + +// Character 返回生效角色(默认 `char_84fb88a3`)。 +func (c *Client) Character() string { return c.cfg.Character } + +// RedactedBaseURL 返回**可安全打日志**的服务地址(只保留 scheme 与 host —— +// 运维若在地址里塞了凭据,日志不该把它写出来)。 +func (c *Client) RedactedBaseURL() string { return redactURL(c.cfg.BaseURL) } + +// Stats 返回当前计数快照。 +func (c *Client) Stats() Stats { + return Stats{Attempts: c.attempts.Load(), Failures: c.failures.Load()} +} + +// Speech 把文本合成成音频。这是本包的**唯一网络出口**:`Service`(降级包装)与 +// 既有语音链的 `TTS` 形状都走它,绝不存在第二处 HTTP 调用。 +// +// 返回值里的 `Format` 是**字节嗅探**所得,不是请求里那个 `response_format`。 +func (c *Client) Speech(ctx context.Context, text string) (Audio, error) { + if !c.cfg.Enabled { + return Audio{}, ErrDisabled + } + trimmed := strings.TrimSpace(text) + if trimmed == "" { + return Audio{}, ErrEmptyText + } + c.attempts.Add(1) + audi, err := c.speak(ctx, trimmed) + if err != nil { + c.failures.Add(1) + } + return audi, err +} + +// speak 是 Speech 的主体:编码 → 发送 → 判状态 → 嗅探。失败原因已分类。 +func (c *Client) speak(ctx context.Context, text string) (Audio, error) { + body, err := encodeSpeech(text, c.cfg.Character, c.cfg.Format) + if err != nil { + return Audio{}, err + } + endpoint := c.cfg.BaseURL + speechPath + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return Audio{}, fmt.Errorf("voice/tts: build request: %w", err) + } + // Content-Type 必须是 application/json:正文本身已是 UTF-8(见文件头)。 + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "audio/*") + + res, err := c.http.Do(req) + if err != nil { + return Audio{}, c.unreachable(ctx, endpoint, err) + } + defer func() { _ = res.Body.Close() }() + + if res.StatusCode != http.StatusOK { + return Audio{}, c.upstreamStatus(res) + } + return c.readAudio(res, text) +} + +// encodeSpeech 把一次请求编码成 **UTF-8 JSON 字节**。 +// +// 🔴 全仓仅此一处构造请求体:`json.Encoder` 天然产出 UTF-8,且这里显式关掉 HTML +// 转义,使正文里的 `<`/`>`/`&` 不会被改写成 `\u003c` 之类(内容逐字不变,下游 +// 日志与音色匹配都依赖原文)。**绝不**改成字符串拼接或 GBK 转码(见文件头)。 +func encodeSpeech(text, character, format string) ([]byte, error) { + payload := speechPayload{ + Model: modelName, + Input: text, + Voice: character, + ResponseFormat: format, + } + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + if err := enc.Encode(&payload); err != nil { + return nil, fmt.Errorf("voice/tts: encode request: %w", err) + } + return buf.Bytes(), nil +} + +// unreachable 把"没拿到响应"收敛成 ErrUnreachable(超时/取消/连接失败都归这里), +// 并记一条带来源的 WARN。正文**不进日志**。 +func (c *Client) unreachable(ctx context.Context, endpoint string, err error) error { + reason := "connection failed" + switch { + case ctx.Err() != nil: + reason = "context canceled" + case errors.Is(err, context.DeadlineExceeded): + reason = "timeout" + case errors.Is(err, context.Canceled): + reason = "context canceled" + } + c.warn("voice tts unreachable", "reason", reason, "endpoint", redactURL(endpoint), "err", err) + return fmt.Errorf("%w: %s: %w", ErrUnreachable, reason, err) +} + +// upstreamStatus 把非 200 收敛成 ErrUpstream,并在日志里留下状态码与一小段 +// 响应片段(上游的错误体是它自己的 `{"detail":...}`;片段只用于排查,不含我方正文)。 +func (c *Client) upstreamStatus(res *http.Response) error { + snippet, _ := io.ReadAll(io.LimitReader(res.Body, 512)) + c.warn("voice tts rejected", "status", res.StatusCode, "detail", string(snippet)) + return fmt.Errorf("%w: status %d", ErrUpstream, res.StatusCode) +} + +// readAudio 读回字节并**按内容裁决**:认不出音频 ⇒ ErrNotAudio,超限 ⇒ ErrTooLarge。 +func (c *Client) readAudio(res *http.Response, text string) (Audio, error) { + raw, err := io.ReadAll(io.LimitReader(res.Body, c.maxLen+1)) + if err != nil { + c.warn("voice tts read body", "err", err) + return Audio{}, fmt.Errorf("%w: read body: %w", ErrUnreachable, err) + } + if int64(len(raw)) > c.maxLen { + c.warn("voice tts response too large", "limit", c.maxLen) + return Audio{}, fmt.Errorf("%w: over %d bytes", ErrTooLarge, c.maxLen) + } + format, ok := sniffAudio(raw) + if !ok { + ctype := res.Header.Get("Content-Type") + c.warn("voice tts response is not audio", "content_type", ctype, "bytes", len(raw), "text_len", len(text)) + return Audio{}, fmt.Errorf("%w: content-type %q, %d bytes", ErrNotAudio, ctype, len(raw)) + } + return Audio{Format: format, Data: raw}, nil +} + +// warn 记一条 WARN(日志器未注入时用默认日志器,绝不因日志缺失而 panic)。 +func (c *Client) warn(msg string, args ...any) { + if c.log == nil { + return + } + c.log.Warn(msg, args...) +} + +// Audio 是一段合成音频及其**嗅探所得**格式。它与 +// `internal/runtime/multimodal/voice.Audio` 字段同形,由 `Service` 一处映射 +// —— 本包因此不依赖那条链的类型,谁都构不成环。 +type Audio struct { + // Format 是字节嗅探所得的编码名(wav/mp3/ogg/flac/m4a/webm/amr)。 + Format string + // Data 是原始音频字节。 + Data []byte +} + +// redactURL 把 baseURL 收敛成**可打日志**的形态:只保留 scheme 与 host, +// 丢掉 userinfo/query(若运维在地址里塞了凭据,别把它写进日志)。 +func redactURL(raw string) string { + u, err := url.Parse(raw) + if err != nil || u.Host == "" { + return raw + } + return u.Scheme + "://" + u.Host +} diff --git a/internal/runtime/voice/tts/client_test.go b/internal/runtime/voice/tts/client_test.go new file mode 100644 index 0000000..3d4dfe4 --- /dev/null +++ b/internal/runtime/voice/tts/client_test.go @@ -0,0 +1,230 @@ +package tts + +// 本文件锁定 `Client` 的五条对外口径(每条都能被"改坏即打红"): +// +// 1. 🔴 **中文正文必须以 UTF-8 送出**(塔实测:GBK 字节会让服务端回 +// `400 {"detail":"There was an error parsing the body"}`)。用例既断言服务端能按 +// UTF-8 解出**逐字相等**的正文,也断言请求字节里能直接搜到那段中文 —— 后者是 +// 把"改成字符串拼接/转码"这类改动的代价变成红灯的钉子。 +// 2. 非 200 ⇒ `ErrUpstream`(且**错误里不带正文**)。 +// 3. 超时 ⇒ `ErrUnreachable` 且保留 `context.DeadlineExceeded`(可 errors.Is)。 +// 4. 200 但不是音频 ⇒ `ErrNotAudio`(绝不把 JSON/HTML 错误页当音频下发)。 +// 5. 未启用 / 空文本 / 超限各自有可分类的哨兵。 + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" +) + +// chineseSpeech 是塔实测那台服务能合成的那句话(保留全角标点,逐字比对)。 +const chineseSpeech = "你好,我是苏梦。今天心情特别好,我们一起出去玩吧!" + +// wavBytes 造一段**魔数正确**的假 WAV(嗅探只看头,内容是什么不影响判据)。 +func wavBytes(payload string) []byte { + b := make([]byte, 0, 44+len(payload)) + b = append(b, []byte("RIFF")...) + b = append(b, 0, 0, 0, 0) + b = append(b, []byte("WAVEfmt ")...) + b = append(b, bytes.Repeat([]byte{0}, 16)...) + b = append(b, []byte("data")...) + b = append(b, 0, 0, 0, 0) + return append(b, []byte(payload)...) +} + +// newTestClient 指向一个 httptest 服务;可变选项留给各用例。 +func newTestClient(t *testing.T, handler http.HandlerFunc, opts ...Option) *Client { + t.Helper() + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + base := []Option{WithLogger(discardLogger())} + base = append(base, opts...) + return New(Config{Enabled: true, BaseURL: srv.URL, Timeout: 2 * time.Second}, base...) +} + +// TestSpeechSendsUTF8ChineseText 是**本切片最重要的一条**:中文正文必须按 UTF-8 +// 字节、以 `application/json` 送出,且字段名/取值与上游 openapi 逐字一致。 +func TestSpeechSendsUTF8ChineseText(t *testing.T) { + var gotPayload speechPayload + var gotRaw []byte + var gotCT string + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + gotCT = r.Header.Get("Content-Type") + gotRaw, _ = io.ReadAll(r.Body) + if err := json.Unmarshal(gotRaw, &gotPayload); err != nil { + t.Errorf("请求体不是合法 JSON(说明有人改成了拼接/转码): %v", err) + } + w.Header().Set("Content-Type", "audio/wav") + _, _ = w.Write(wavBytes("ok")) + }) + + audio, err := client.Speech(context.Background(), chineseSpeech) + if err != nil { + t.Fatalf("Speech: %v", err) + } + if gotCT != "application/json" { + t.Errorf("Content-Type = %q, want application/json", gotCT) + } + // ① 服务端按 UTF-8 解出来必须**逐字相等**(GBK 字节在这里必然对不上)。 + if gotPayload.Input != chineseSpeech { + t.Fatalf("input = %q, want %q", gotPayload.Input, chineseSpeech) + } + // ② 请求字节里必须能直接搜到那段中文的 UTF-8 编码 —— 一旦有人改成 GBK 转码, + // 这里立刻红(UTF-8 的"你好"= E4BD A0 E5A5 BD)。 + if !bytes.Contains(gotRaw, []byte(chineseSpeech)) { + t.Fatalf("请求体里找不到 UTF-8 正文(字节被转码了?): %q", string(gotRaw)) + } + if !bytes.Contains(gotRaw, []byte("\xe4\xbd\xa0\xe5\xa5\xbd")) { + t.Fatalf("请求体里找不到「你好」的 UTF-8 字节") + } + if gotPayload.Model != modelName { + t.Errorf("model = %q, want %q", gotPayload.Model, modelName) + } + if gotPayload.Voice != DefaultCharacter { + t.Errorf("voice = %q, want %q", gotPayload.Voice, DefaultCharacter) + } + if gotPayload.ResponseFormat != DefaultFormat { + t.Errorf("response_format = %q, want %q", gotPayload.ResponseFormat, DefaultFormat) + } + // ③ 回发的格式由**字节嗅探**裁决,不是请求里那个字段。 + if audio.Format != "wav" || len(audio.Data) == 0 { + t.Fatalf("audio = %+v, want wav with bytes", audio) + } +} + +// TestSpeechHTMLEscapingDisabled 钉住"正文逐字不变":`<`/`>`/`&` 不得被改写成 \u003c。 +func TestSpeechHTMLEscapingDisabled(t *testing.T) { + var gotRaw []byte + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + gotRaw, _ = io.ReadAll(r.Body) + _, _ = w.Write(wavBytes("ok")) + }) + if _, err := client.Speech(context.Background(), "ad"); err != nil { + t.Fatalf("Speech: %v", err) + } + if bytes.Contains(gotRaw, []byte(`\u003c`)) || bytes.Contains(gotRaw, []byte(`\u0026`)) { + t.Fatalf("正文被 HTML 转义了: %q", string(gotRaw)) + } +} + +// TestSpeechRejectsNon200 钉住上游拒绝:可分类、带状态码、**不带正文**。 +func TestSpeechRejectsNon200(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"detail":"There was an error parsing the body"}`)) + }) + _, err := client.Speech(context.Background(), chineseSpeech) + if !errors.Is(err, ErrUpstream) { + t.Fatalf("err = %v, want ErrUpstream", err) + } + if !strings.Contains(err.Error(), "400") { + t.Errorf("错误里应带状态码: %v", err) + } + if strings.Contains(err.Error(), "苏梦") { + t.Errorf("错误链里出现了正文(红线:日志/错误不带正文): %v", err) + } + if got := client.Stats(); got.Attempts != 1 || got.Failures != 1 { + t.Errorf("stats = %+v, want 1 attempt / 1 failure", got) + } +} + +// TestSpeechTimeout 钉住超时:归 ErrUnreachable,且因果链仍能判出 deadline。 +func TestSpeechTimeout(t *testing.T) { + release := make(chan struct{}) + t.Cleanup(func() { close(release) }) + handler := func(w http.ResponseWriter, r *http.Request) { + select { + case <-release: + case <-time.After(2 * time.Second): + } + _, _ = w.Write(wavBytes("late")) + } + client := newTestClient(t, handler, WithHTTPClient(&http.Client{Timeout: 60 * time.Millisecond})) + start := time.Now() + _, err := client.Speech(context.Background(), chineseSpeech) + if !errors.Is(err, ErrUnreachable) { + t.Fatalf("err = %v, want ErrUnreachable", err) + } + if !errors.Is(err, context.DeadlineExceeded) { + t.Errorf("err = %v, want wraps context.DeadlineExceeded", err) + } + if elapsed := time.Since(start); elapsed > time.Second { + t.Errorf("超时没有生效(用了 %v)", elapsed) + } +} + +// TestSpeechRejectsNonAudio 钉住"200 但不是音频":JSON 错误页绝不当音频。 +func TestSpeechRejectsNonAudio(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":"success","audio_url":"/outputs/x.wav"}`)) + }) + _, err := client.Speech(context.Background(), chineseSpeech) + if !errors.Is(err, ErrNotAudio) { + t.Fatalf("err = %v, want ErrNotAudio", err) + } + if got := client.Stats(); got.Failures != 1 { + t.Errorf("stats = %+v, want 1 failure", got) + } +} + +// TestSpeechRejectsOversizeResponse 钉住体积上限。 +func TestSpeechRejectsOversizeResponse(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(wavBytes(strings.Repeat("x", 128))) + }, WithMaxResponseBytes(32)) + _, err := client.Speech(context.Background(), chineseSpeech) + if !errors.Is(err, ErrTooLarge) { + t.Fatalf("err = %v, want ErrTooLarge", err) + } +} + +// TestSpeechGuards 钉住"不该发网络"的两条前置拒绝,以及"未启用"的可区分性。 +func TestSpeechGuards(t *testing.T) { + var hits atomic.Int64 + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + _, _ = w.Write(wavBytes("ok")) + }) + + if _, err := client.Speech(context.Background(), " "); !errors.Is(err, ErrEmptyText) { + t.Errorf("空文本 err = %v, want ErrEmptyText", err) + } + disabled := New(Config{Enabled: false, BaseURL: client.cfg.BaseURL}, WithLogger(discardLogger())) + if _, err := disabled.Speech(context.Background(), chineseSpeech); !errors.Is(err, ErrDisabled) { + t.Errorf("未启用 err = %v, want ErrDisabled", err) + } + // 被拒的两次都不该打网络;未启用也不该被计成"失败"。 + if got := hits.Load(); got != 0 { + t.Errorf("上游被调用 %d 次,want 0", got) + } + if got := disabled.Stats(); got.Attempts != 0 || got.Failures != 0 { + t.Errorf("未启用却计了数: %+v", got) + } +} + +// TestConfigNormalized 钉住默认值与非法格式的回落(不让一个写错的格式关掉语音面)。 +func TestConfigNormalized(t *testing.T) { + got := Config{}.Normalized(discardLogger()) + if got.Enabled { + t.Errorf("零值配置不该被悄悄启用") + } + if got.BaseURL != DefaultBaseURL || got.Character != DefaultCharacter || + got.Format != DefaultFormat || got.Timeout != DefaultTimeout { + t.Fatalf("默认值不对: %+v", got) + } + if custom := (Config{BaseURL: " http://127.0.0.1:9880/ ", Format: "MP3"}).Normalized(discardLogger()); custom.BaseURL != DefaultBaseURL || custom.Format != "mp3" { + t.Errorf("归一化不对: %+v", custom) + } + if bad := (Config{Format: "exe"}).Normalized(discardLogger()); bad.Format != DefaultFormat { + t.Errorf("非法格式应回落默认: %+v", bad) + } +} diff --git a/internal/runtime/voice/tts/config.go b/internal/runtime/voice/tts/config.go new file mode 100644 index 0000000..88c48f7 --- /dev/null +++ b/internal/runtime/voice/tts/config.go @@ -0,0 +1,115 @@ +// Package tts 是**半云端 emotionTTS 服务**的运行时合成适配(切片 M35)。 +// +// 它与父包 `internal/runtime/voice` 的分工(**别混**):父包管「一条入站语音的 +// 来源事实 + 可播副本」(design/24 §10);本包只回答一件事 ——「把这段文字交给 +// emotionTTS 换成音频」。它与 `internal/runtime/multimodal/voice` 里那条 STT/TTS +// 降级链**不是**两套 HTTP:本包的 `Client` 满足那条链的 `TTS` 形状,`Service` +// 则把 emotionTTS 摆在那条链**前面**(失败/未启用回落既有链)。 +// +// # 为什么它比我方参数"更懂情绪" +// +// 该服务(v5.1.0)的 `POST /v1/audio/speech` **自己**做情绪:先让 LLM 分析待念 +// 文本的情绪、在角色素材库里挑一条情绪相符的参考音,再把情绪向量以 +// `[EMO:{vector}|{alpha}]base64:...` 的形式塞进 `voice` 字段交给 indexTTS2 +// (`core/routers/tts_router.py` 的 `openai_tts_api` + `core/utils/llm_provider.py` +// 的 `synthesize_audio`)。**因此我方没有、也不需要情绪参数** —— 语气由要念的 +// 文本自己带(她调措辞就是调语气)。 +// +// # 为什么没有 speed 旋钮 +// +// 🔴 同一个 `synthesize_audio` 把下游载荷的 `speed` **硬编码成 1.0**(同一文件 +// 的 `data_json`),所以 OpenAI 兼容口的 `speed` 收下即丢。**给它做一个旋钮等于 +// 做一个假开关** —— 因此 `Config` 刻意不含 `speed`。同理不含 `emotion`/`tone`: +// 那两样上游自己推,我方传什么都是噪音。 +// +// # 一次只暴露三样 +// +// 对调用方只暴露 `text`(必填)、角色(默认 `Cyrene` 的 `char_84fb88a3`)与 +// `format`(默认 wav)。其余全部是代码默认值或面板文件里的运维旋钮。 +package tts + +import ( + "log/slog" + "strings" + "time" +) + +const ( + // DefaultBaseURL 是该服务的默认地址(**只绑回环**、无鉴权:绝不是公网地址)。 + DefaultBaseURL = "http://127.0.0.1:9880" + + // DefaultCharacter 是默认角色 —— 主人指定的「Cyrene」(`GET /api/characters` + // 实测 id = `char_84fb88a3`)。 + DefaultCharacter = "char_84fb88a3" + + // DefaultFormat 是默认请求格式。服务端产物**恒为 WAV**(它把 .wav 落盘后用 + // `FileResponse` 回发,`response_format` 只影响它自报的 media_type),因此默认 + // wav;实际下发的格式仍由**字节嗅探**裁决(见 audio.go),不信任这个字段。 + DefaultFormat = "wav" + + // DefaultTimeout 是单次合成超时。实测一次中文合成约 4~6 秒(它内部还要先跑一次 + // LLM 情绪分析 + 素材匹配),故默认留足余量;超时即失败并回落既有链。 + DefaultTimeout = 60 * time.Second + + // defaultMaxAudioBytes 限制读回的音频体积,使异常/畸形响应无法耗尽内存 + // (32 MiB 远超一句话的语音,实测 5 秒语音约 100~200 KB)。 + defaultMaxAudioBytes = 32 << 20 + + // speechPath 是 OpenAI 兼容合成端点(已实测可用:200 / RIFF-WAVE)。 + speechPath = "/v1/audio/speech" + + // modelName 是上游要求的固定模型名(该服务只认 `emotionTTS`)。 + modelName = "emotionTTS" +) + +// Config 是 emotionTTS 客户端的运行期旋钮(由面板文件 +// `$DATA_DIR/config/voice-tts.json` 映射而来;零新 `.env` 键)。 +// +// 🔴 刻意**没有** speed / emotion / tone 三项,理由见包注释(上游硬编码或自推)。 +type Config struct { + // Enabled 是总开关。false = 该级整体不参与(调用方应保留既有链, + // 而不是把"没启用"当成"合成失败")。 + Enabled bool + // BaseURL 是服务地址;空 = DefaultBaseURL。约定只填回环地址。 + BaseURL string + // Character 是角色(服务端 `/api/characters` 的 id 或名字);空 = DefaultCharacter。 + Character string + // Format 是请求格式(`response_format`);空 = DefaultFormat。只接受 + // wav/mp3/ogg —— 且**只用于请求**,回发的真实格式一律按字节嗅探。 + Format string + // Timeout 是单次合成超时;<=0 = DefaultTimeout。 + Timeout time.Duration +} + +// supportedFormats 是允许写进 Format 的取值(上游 `response_format` 只用于自报 +// media_type;产物恒为 WAV)。写别的值不是"更灵活",而是让下游拿到一个与字节 +// 不符的标签 —— 因此这里**拒绝**而不是静默放行。 +var supportedFormats = map[string]bool{"wav": true, "mp3": true, "ogg": true} + +// Normalized 返回填好默认值、去掉首尾空白的配置。非法 Format 回落到默认值并 +// 通过 log 记一条 WARN(**不让它停摆** —— 一个写错的格式不该关掉整个语音面)。 +func (c Config) Normalized(log *slog.Logger) Config { + out := c + out.BaseURL = strings.TrimRight(strings.TrimSpace(out.BaseURL), "/") + if out.BaseURL == "" { + out.BaseURL = DefaultBaseURL + } + out.Character = strings.TrimSpace(out.Character) + if out.Character == "" { + out.Character = DefaultCharacter + } + out.Format = strings.ToLower(strings.TrimSpace(out.Format)) + if out.Format == "" { + out.Format = DefaultFormat + } else if !supportedFormats[out.Format] { + if log != nil { + log.Warn("unsupported tts format, fell back to default", + "format", out.Format, "default", DefaultFormat) + } + out.Format = DefaultFormat + } + if out.Timeout <= 0 { + out.Timeout = DefaultTimeout + } + return out +} diff --git a/internal/runtime/voice/tts/live_test.go b/internal/runtime/voice/tts/live_test.go new file mode 100644 index 0000000..3920f18 --- /dev/null +++ b/internal/runtime/voice/tts/live_test.go @@ -0,0 +1,40 @@ +//go:build emotiontts_live + +package tts + +import ( + "context" + "os" + "testing" + "time" +) + +// 本文件是**对着真的 emotionTTS 服务**的端到端验证,默认不进 `go test ./...` +// (与 `dashscope_live` 同一口径:不得让常规测试去碰外部服务)。 +// +// 显式运行(服务默认在 127.0.0.1:9880,可用 EMOTIONTTS_BASE_URL 覆盖): +// +// go test -tags emotiontts_live -run TestLiveSpeech -v ./internal/runtime/voice/tts/ +func TestLiveSpeech(t *testing.T) { + baseURL := os.Getenv("EMOTIONTTS_BASE_URL") + client := New(Config{Enabled: true, BaseURL: baseURL, Timeout: 120 * time.Second}) + + start := time.Now() + audio, err := client.Speech(context.Background(), chineseSpeech) + if err != nil { + t.Fatalf("对真服务合成失败: %v", err) + } + elapsed := time.Since(start) + if audio.Format != "wav" { + t.Errorf("format = %q, want wav(产物恒为 WAV)", audio.Format) + } + if len(audio.Data) == 0 { + t.Fatal("拿到的音频为空") + } + if string(audio.Data[:4]) != "RIFF" || string(audio.Data[8:12]) != "WAVE" { + t.Fatalf("不是 RIFF/WAVE: %q", string(audio.Data[:12])) + } + // 证据:真实字节数与耗时(塔要求的"实际字节")。 + t.Logf("evidence: base=%s character=%s bytes=%d format=%s elapsed=%s", + client.Config().BaseURL, client.Character(), len(audio.Data), audio.Format, elapsed) +} diff --git a/internal/runtime/voice/tts/provider.go b/internal/runtime/voice/tts/provider.go new file mode 100644 index 0000000..708fbdf --- /dev/null +++ b/internal/runtime/voice/tts/provider.go @@ -0,0 +1,47 @@ +package tts + +// 本文件把 `*Client` 变成一个**既有语音链的一级**(`mvoice.Provider`)。 +// +// 当前组装走的是 `Service`(包在整条链前面,失败回落),不是把本级插进链里 +// (`multimodal/voice.New` 组装的各级列表不外露,插进去要先改那条链,超出本切片 +// 范围)。提供这个适配是为了让"作为一级"这条路**现在就是通的**:一旦组装方式 +// 改成显式列级(`voice.NewChain(...)`),本级可以原样进链,不必重写 HTTP。 + +import ( + "context" + + mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice" +) + +// Provider 是 emotionTTS 作为降级链一级的适配:只提供 TTS,STT 方向返回 nil +// (它不做转写;链遇到 nil 会自动跳过这一级去找下一个能转写的)。 +type Provider struct { + client *Client +} + +// NewProvider 用客户端构造一级。client 为 nil 时 Name 仍可用,TTS 返回错误。 +func NewProvider(client *Client) *Provider { return &Provider{client: client} } + +// Name 返回稳定的级别名(降级信号里就显示这个)。 +func (p *Provider) Name() string { return ProviderName } + +// STT 返回 nil:本一级不提供转写。 +func (p *Provider) STT() mvoice.STT { return nil } + +// TTS 返回本一级的合成实现。 +func (p *Provider) TTS() mvoice.TTS { return ttsAdapter{p.client} } + +// ttsAdapter 把 `Client.Speech` 适配成链要求的 `TTS` 形状(`Audio` 类型不同)。 +type ttsAdapter struct{ client *Client } + +// TTS 合成音频;client 未接线时返回明确错误而不是 panic。 +func (a ttsAdapter) TTS(ctx context.Context, text string) (mvoice.Audio, error) { + if a.client == nil { + return mvoice.Audio{}, mvoice.ErrNotConfigured + } + audio, err := a.client.Speech(ctx, text) + if err != nil { + return mvoice.Audio{}, err + } + return mvoice.Audio{Format: audio.Format, Data: audio.Data}, nil +} diff --git a/internal/runtime/voice/tts/service.go b/internal/runtime/voice/tts/service.go new file mode 100644 index 0000000..4ab3d8f --- /dev/null +++ b/internal/runtime/voice/tts/service.go @@ -0,0 +1,161 @@ +package tts + +// 本文件把 emotionTTS 摆到**既有语音链之前**,并在它失败时回落既有链。 +// +// 为什么是"包一层"而不是"在链里插一级":既有链由 `internal/runtime/multimodal/voice` +// 的 `New` 按 env 配置组装(cloud → local → edge-tts/espeak → silent),它的各级 +// 列表不外露;而 `.env` 已被主人冻结,**不能**为这条新能力加 env 键。因此本切片 +// 不改那条链的任何一行,只提供 `*Service` 这一层包装:合成优先走 emotionTTS, +// 未启用/失败则原样委托既有链。 +// +// 两条既有出站口(`POST /api/v1/audio/speech` 与 WS `voice_output`)都通过 +// 网关的 `SetVoice` 拿到同一个 `VoiceService`,因此**只要换这一个注入点**,两条口 +// 就都用上了它 —— 不新建端点、不加帧族(切片红线)。 +// +// 🔴 **文本优先**:本层**永不**把 TTS 的失败上抛成"这条回复失败"。失败只做两件事: +// 记一条带来源的 WARN + 计数(`Client.Stats`),然后回落既有链;既有链再失败才算 +// 整体失败,而那时聊天正文早已发出/落库(TTS 是呈现层的增强,不是回复的前置条件)。 + +import ( + "context" + "log/slog" + + mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice" +) + +// ProviderName 是本级在降级信号里的名字(既有链用 cloud/local/edge-tts/silent +// 这类小写短名,此处沿用同一形态)。 +const ProviderName = "emotiontts" + +// logSource 是本包日志的来源标签(AGENTS §10:日志必须标来源)。 +const logSource = "runtime/voice/tts" + +// Chain 是**既有语音链的最小面**:本包只用到这五个方法,`*mvoice.Chain` 全部满足。 +// 用窄接口而不是直接依赖 `*mvoice.Chain`,是为了让测试能给一个假底链。 +type Chain interface { + // STT 转写音频(emotionTTS 不做转写,原样委托)。 + STT(ctx context.Context, audio mvoice.Audio) (string, error) + // TTS 合成音频(本层未启用/失败时走这里)。 + TTS(ctx context.Context, text string) (mvoice.Audio, error) + // CloneVoice 声音复刻(原样委托)。 + CloneVoice(ctx context.Context, referenceURL, prefix string) (string, error) + // STTWithProvider 同 STT,但报告实际服务的那一级。 + STTWithProvider(ctx context.Context, audio mvoice.Audio) (string, string, error) + // TTSWithProvider 同 TTS,但报告实际服务的那一级。 + TTSWithProvider(ctx context.Context, text string) (mvoice.Audio, string, error) +} + +// Service 是 emotionTTS + 既有链的合成门面。它既满足网关的 `VoiceService` +// (STT/TTS/CloneVoice),也满足其可选扩展 `voiceProviderReporter` +// (STTWithProvider/TTSWithProvider),因此面板能看到"这次是哪一级出的声"。 +type Service struct { + primary *Client + base Chain + log *slog.Logger +} + +// ServiceOption 定制 Service 的日志器。 +type ServiceOption func(*Service) + +// WithServiceLogger 注入带来源的日志器;未注入时退回默认日志器。 +func WithServiceLogger(l *slog.Logger) ServiceOption { + return func(s *Service) { s.log = l } +} + +// NewService 用 emotionTTS 客户端包住既有链。primary 为 nil 或未启用时,本层 +// **完全透传**既有链(行为与切片引入之前逐字相同);base 为 nil 时无从回落, +// 合成会回一个明确的错误(不 panic)。 +func NewService(primary *Client, base Chain, opts ...ServiceOption) *Service { + s := &Service{primary: primary, base: base, log: slog.Default()} + for _, o := range opts { + o(s) + } + if s.log == nil { + s.log = slog.Default() + } + return s +} + +// PrimaryActive 报告 emotionTTS 这一级是否真的在生效(面板据此如实显示"已接")。 +func (s *Service) PrimaryActive() bool { return s != nil && s.primary != nil && s.primary.Enabled() } + +// Character 返回生效角色(面板展示用)。 +func (s *Service) Character() string { + if s == nil || s.primary == nil { + return "" + } + return s.primary.Character() +} + +// Stats 返回 emotionTTS 这一级的计数(尝试/失败);未接线时为 0 值。 +func (s *Service) Stats() Stats { + if s == nil || s.primary == nil { + return Stats{} + } + return s.primary.Stats() +} + +// STT 原样委托既有链 —— emotionTTS 只做合成,不抢转写的活。 +func (s *Service) STT(ctx context.Context, audio mvoice.Audio) (string, error) { + if s.base == nil { + return "", mvoice.ErrProviderFailed + } + return s.base.STT(ctx, audio) +} + +// STTWithProvider 原样委托既有链(同时透传它报告的那一级)。 +func (s *Service) STTWithProvider(ctx context.Context, audio mvoice.Audio) (string, string, error) { + if s.base == nil { + return "", "", mvoice.ErrProviderFailed + } + return s.base.STTWithProvider(ctx, audio) +} + +// CloneVoice 原样委托既有链(声音复刻是云端那级的活)。 +func (s *Service) CloneVoice(ctx context.Context, referenceURL, prefix string) (string, error) { + if s.base == nil { + return "", mvoice.ErrProviderFailed + } + return s.base.CloneVoice(ctx, referenceURL, prefix) +} + +// TTS 合成音频(不报告级别名;网关的口径同既有链)。 +func (s *Service) TTS(ctx context.Context, text string) (mvoice.Audio, error) { + audio, _, err := s.TTSWithProvider(ctx, text) + return audio, err +} + +// TTSWithProvider 先试 emotionTTS,失败/未启用则回落既有链,并如实报告实际 +// 服务的那一级(`emotiontts` 或既有链的 cloud/local/...)。 +// +// 三条纪律: +// - **失败绝不上抛成回复失败**:先回落,回落也失败才把后者的错误交给调用方 +// (既有行为逐字不变); +// - **WARN 带来源**:哪一级、什么原因,一条说清(正文绝不进日志,只有长度); +// - **未启用不是故障**:只记 DEBUG,不刷 WARN。 +func (s *Service) TTSWithProvider(ctx context.Context, text string) (mvoice.Audio, string, error) { + if s.PrimaryActive() { + audio, err := s.primary.Speech(ctx, text) + if err == nil { + return mvoice.Audio{Format: audio.Format, Data: audio.Data}, ProviderName, nil + } + s.degrade(ctx, text, err) + } + if s.base == nil { + return mvoice.Audio{}, "", mvoice.ErrProviderFailed + } + return s.base.TTSWithProvider(ctx, text) +} + +// degrade 记录一次 emotionTTS 降级:未启用是 DEBUG(正常状态),其余是 WARN +// (异常状态,但已被回落吸收)。日志只带文本长度,**绝不带正文**。 +func (s *Service) degrade(ctx context.Context, text string, err error) { + if ctx.Err() != nil { + // 回合被取消/超时的情形已由 Client 记过(unreachable),这里只补一句 + // 「因此这次没声音」,避免同一条失败刷两条 WARN。 + s.log.Debug("voice tts skipped after cancel", "provider", ProviderName, "text_len", len(text)) + return + } + s.log.Warn("voice tts degraded to fallback chain", + "provider", ProviderName, "text_len", len(text), "err", err) +} diff --git a/internal/runtime/voice/tts/service_test.go b/internal/runtime/voice/tts/service_test.go new file mode 100644 index 0000000..3c96530 --- /dev/null +++ b/internal/runtime/voice/tts/service_test.go @@ -0,0 +1,178 @@ +package tts + +// 本文件锁定 `Service`(emotionTTS + 既有链的门面)的三条口径: +// +// 1. **健康时用 emotionTTS**,并如实报告级别名 `emotiontts`; +// 2. **失败时回落既有链**,且**不把失败上抛**(文本优先:合成挂掉不影响回复); +// 3. **未启用时完全透传**(行为与引入本切片之前逐字相同),STT/克隆永远走既有链。 + +import ( + "context" + "errors" + "io" + "log/slog" + "net/http" + "testing" + + mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice" +) + +// discardLogger 让测试不往 stderr 刷日志(WARN 的存在由行为断言,不靠肉眼)。 +func discardLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +// fakeChain 是一个可编程的底链替身,记录自己被调了几次。 +type fakeChain struct { + ttsCalls int + sttCalls int + cloneCall int + audio mvoice.Audio + err error +} + +func (f *fakeChain) STT(context.Context, mvoice.Audio) (string, error) { + f.sttCalls++ + return "转写文本", nil +} + +func (f *fakeChain) STTWithProvider(context.Context, mvoice.Audio) (string, string, error) { + f.sttCalls++ + return "转写文本", "local", nil +} + +func (f *fakeChain) TTS(context.Context, string) (mvoice.Audio, error) { + f.ttsCalls++ + return f.audio, f.err +} + +func (f *fakeChain) TTSWithProvider(context.Context, string) (mvoice.Audio, string, error) { + f.ttsCalls++ + return f.audio, "silent", f.err +} + +func (f *fakeChain) CloneVoice(context.Context, string, string) (string, error) { + f.cloneCall++ + return "voice-id", nil +} + +// TestServiceUsesPrimaryWhenHealthy 钉住主路:健康时走 emotionTTS 且报告级别名。 +func TestServiceUsesPrimaryWhenHealthy(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(wavBytes("her voice")) + }) + base := &fakeChain{audio: mvoice.Audio{Format: "mp3", Data: []byte("fallback")}} + svc := NewService(client, base, WithServiceLogger(discardLogger())) + + audio, rung, err := svc.TTSWithProvider(context.Background(), chineseSpeech) + if err != nil { + t.Fatalf("TTSWithProvider: %v", err) + } + if rung != ProviderName { + t.Errorf("rung = %q, want %q", rung, ProviderName) + } + if audio.Format != "wav" || string(audio.Data) == "fallback" { + t.Errorf("audio = %+v, want the emotionTTS bytes", audio) + } + if base.ttsCalls != 0 { + t.Errorf("主路健康却回落了底链 %d 次", base.ttsCalls) + } +} + +// TestServiceFallsBackWhenPrimaryFails 钉住降级:主路失败 ⇒ 底链出声音, +// **错误不上抛**(文本优先)。 +func TestServiceFallsBackWhenPrimaryFails(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"detail":"indexTTS2 down"}`)) + }) + base := &fakeChain{audio: mvoice.Audio{Format: "mp3", Data: []byte("fallback")}} + svc := NewService(client, base, WithServiceLogger(discardLogger())) + + audio, rung, err := svc.TTSWithProvider(context.Background(), chineseSpeech) + if err != nil { + t.Fatalf("主路失败不该让合成整体失败(文本优先): %v", err) + } + if rung != "silent" { + t.Errorf("rung = %q, want 底链报告的那一级", rung) + } + if string(audio.Data) != "fallback" { + t.Errorf("audio = %+v, want 底链产物", audio) + } + if base.ttsCalls != 1 { + t.Errorf("底链被调用 %d 次, want 1", base.ttsCalls) + } + if got := svc.Stats(); got.Failures != 1 { + t.Errorf("降级没有计数: %+v", got) + } +} + +// TestServiceDisabledIsTransparent 钉住"未启用 = 行为逐字不变":底链原样服务, +// 一次都不碰 emotionTTS 的 HTTP。 +func TestServiceDisabledIsTransparent(t *testing.T) { + client := New(Config{Enabled: false, BaseURL: "http://127.0.0.1:1"}, WithLogger(discardLogger())) + if client.Enabled() { + t.Fatal("未启用的客户端不该报告 enabled") + } + base := &fakeChain{audio: mvoice.Audio{Format: "ogg", Data: []byte("chain")}} + svc := NewService(client, base, WithServiceLogger(discardLogger())) + if svc.PrimaryActive() { + t.Fatal("PrimaryActive = true, want false") + } + audio, _, err := svc.TTSWithProvider(context.Background(), chineseSpeech) + if err != nil { + t.Fatalf("TTSWithProvider: %v", err) + } + if audio.Format != "ogg" || base.ttsCalls != 1 { + t.Errorf("未启用时没有原样透传: audio=%+v calls=%d", audio, base.ttsCalls) + } +} + +// TestServiceWithoutBase 钉住"没有底链"时的诚实失败(不 panic、可分类)。 +func TestServiceWithoutBase(t *testing.T) { + svc := NewService(nil, nil, WithServiceLogger(discardLogger())) + if _, _, err := svc.TTSWithProvider(context.Background(), "hi"); !errors.Is(err, mvoice.ErrProviderFailed) { + t.Fatalf("err = %v, want ErrProviderFailed", err) + } + if _, err := svc.STT(context.Background(), mvoice.Audio{}); !errors.Is(err, mvoice.ErrProviderFailed) { + t.Fatalf("STT err = %v, want ErrProviderFailed", err) + } + if _, err := svc.CloneVoice(context.Background(), "http://x", "p"); !errors.Is(err, mvoice.ErrProviderFailed) { + t.Fatalf("CloneVoice err = %v, want ErrProviderFailed", err) + } +} + +// TestServiceDelegatesSTTAndClone 钉住"只抢合成,不抢转写/复刻"。 +func TestServiceDelegatesSTTAndClone(t *testing.T) { + client := New(Config{Enabled: true, BaseURL: "http://127.0.0.1:1"}, WithLogger(discardLogger())) + base := &fakeChain{} + svc := NewService(client, base, WithServiceLogger(discardLogger())) + + if text, rung, err := svc.STTWithProvider(context.Background(), mvoice.Audio{}); err != nil || text != "转写文本" || rung != "local" { + t.Fatalf("STT 没有原样委托: text=%q rung=%q err=%v", text, rung, err) + } + if id, err := svc.CloneVoice(context.Background(), "http://x", "p"); err != nil || id != "voice-id" { + t.Fatalf("CloneVoice 没有原样委托: id=%q err=%v", id, err) + } + if base.sttCalls != 1 || base.cloneCall != 1 { + t.Errorf("委托次数不对: stt=%d clone=%d", base.sttCalls, base.cloneCall) + } +} + +// TestProviderAdapter 钉住"作为链的一级"这条路现在就是通的(STT 方向为 nil)。 +func TestProviderAdapter(t *testing.T) { + client := New(Config{Enabled: true, BaseURL: "http://127.0.0.1:1"}, WithLogger(discardLogger())) + p := NewProvider(client) + if p.Name() != ProviderName { + t.Errorf("Name = %q, want %q", p.Name(), ProviderName) + } + if p.STT() != nil { + t.Error("emotionTTS 不提供转写,STT 应为 nil") + } + if _, err := p.TTS().TTS(context.Background(), " "); !errors.Is(err, ErrEmptyText) { + t.Errorf("err = %v, want ErrEmptyText(穿透到 Client 的守卫)", err) + } + if _, err := NewProvider(nil).TTS().TTS(context.Background(), "hi"); !errors.Is(err, mvoice.ErrNotConfigured) { + t.Errorf("未接线 err = %v, want ErrNotConfigured", err) + } +} diff --git a/internal/services/gateway/voice_emotiontts_test.go b/internal/services/gateway/voice_emotiontts_test.go new file mode 100644 index 0000000..9ab4051 --- /dev/null +++ b/internal/services/gateway/voice_emotiontts_test.go @@ -0,0 +1,145 @@ +package gateway + +// 本文件是切片 M35 的**两条既有出站口实测**:把真的 emotionTTS provider +// (指向一个 httptest 上游)注入网关,然后 +// +// 1. `POST /api/v1/audio/speech` 回的**是音频字节**(而不是 JSON 信封/空体); +// 2. WS `voice_output` 帧里带的**是那段音频的 base64**。 +// +// 它钉的是"接上 provider ≠ 用户能听到"这件事:两条口各自的输出都被逐字节断言过。 + +import ( + "context" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" + mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice" + voicetts "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/tts" +) + +// fakeWAV 是一段魔数合法的假 WAV(本文件的断言只关心"字节原样跑到客户端")。 +func fakeWAV() []byte { + b := []byte("RIFF\x00\x00\x00\x00WAVEfmt ") + b = append(b, make([]byte, 16)...) + b = append(b, []byte("data\x00\x00\x00\x00")...) + return append(b, []byte("her-emotion-voice")...) +} + +// emotionTTSServer 起一台假的 emotionTTS:校验请求是 UTF-8 JSON,回一段 WAV。 +func emotionTTSServer(t *testing.T) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/audio/speech" { + t.Errorf("path = %q, want /v1/audio/speech", r.URL.Path) + } + var payload struct { + Model string `json:"model"` + Input string `json:"input"` + Voice string `json:"voice"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Errorf("请求体不是合法 UTF-8 JSON: %v", err) + } + if payload.Voice != voicetts.DefaultCharacter || payload.Model != "emotionTTS" { + t.Errorf("payload = %+v, want emotionTTS / 默认角色", payload) + } + w.Header().Set("Content-Type", "audio/wav") + _, _ = w.Write(fakeWAV()) + })) + t.Cleanup(srv.Close) + return srv +} + +// emotionTTSService 用假上游构造一个**真的** tts.Service(不是 stub)。 +func emotionTTSService(t *testing.T, base voicetts.Chain) *voicetts.Service { + t.Helper() + srv := emotionTTSServer(t) + client := voicetts.New(voicetts.Config{Enabled: true, BaseURL: srv.URL}) + return voicetts.NewService(client, base) +} + +// TestSpeechEndpointServesEmotionTTSAudio 是 REST 口(`POST /api/v1/audio/speech`) +// 的实测证据:状态 200、Content-Type 音频、体就是上游那串字节。 +func TestSpeechEndpointServesEmotionTTSAudio(t *testing.T) { + env := newEnv(t, nil, false) + env.srv.SetVoice(emotionTTSService(t, nil)) + + token := env.token(t, auth.RoleOwner) + resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/audio/speech", map[string]any{ + "text": "你好,我是苏梦。", + }, token) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, body = %s", resp.StatusCode, body) + } + if ct := resp.Header.Get("Content-Type"); ct != "audio/wav" { + t.Errorf("Content-Type = %q, want audio/wav", ct) + } + if len(body) == 0 { + t.Fatal("响应体为空:用户听不到任何声音") + } + if string(body) != string(fakeWAV()) { + t.Fatalf("body 不是上游音频字节: %d bytes", len(body)) + } +} + +// TestVoiceOutputFrameCarriesEmotionTTSAudio 是 WS 口(`voice_output` 帧)的实测 +// 证据:帧里 audio 字段解码后就是上游那串字节,且 text/reply 都在。 +func TestVoiceOutputFrameCarriesEmotionTTSAudio(t *testing.T) { + hb := newTestHub(&stubRunner{res: &loop.Result{Text: "你好,我是苏梦。", Steps: 1, StopReason: "stop"}}, nil) + hb.voice = emotionTTSService(t, &silentBase{}) + c := newOwnerConn(hb, 32) + hb.register(c) + + c.route(wsInbound{Type: "voice_input", Data: json.RawMessage(`{"text":"你好","format":"wav"}`)}) + ev := readFakeFrame(t, c) + if ev.Type != "voice_output" { + t.Fatalf("frame = %q, want voice_output (%+v)", ev.Type, ev) + } + data, ok := ev.Data.(map[string]any) + if !ok { + t.Fatalf("frame data = %T", ev.Data) + } + encoded, _ := data["audio"].(string) + if encoded == "" { + t.Fatal("帧里没有 audio:用户听不到任何声音") + } + raw, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + t.Fatalf("audio 不是合法 base64: %v", err) + } + if string(raw) != string(fakeWAV()) { + t.Fatalf("帧里的音频不是上游字节: %d bytes", len(raw)) + } + if data["reply"] != "你好,我是苏梦。" { + t.Errorf("reply = %v, want 助手正文", data["reply"]) + } +} + +// silentBase 是最小的底链替身:只在测"主路健康"时用作类型占位, +// 一旦被调用就说明主路降级了(测试会因此失败)。 +type silentBase struct{} + +func (s *silentBase) STT(_ context.Context, _ mvoice.Audio) (string, error) { + return "", mvoice.ErrProviderFailed +} + +func (s *silentBase) STTWithProvider(_ context.Context, _ mvoice.Audio) (string, string, error) { + return "", "", mvoice.ErrProviderFailed +} + +func (s *silentBase) TTS(_ context.Context, _ string) (mvoice.Audio, error) { + return mvoice.Audio{}, mvoice.ErrProviderFailed +} + +func (s *silentBase) TTSWithProvider(_ context.Context, _ string) (mvoice.Audio, string, error) { + return mvoice.Audio{}, "", mvoice.ErrProviderFailed +} + +func (s *silentBase) CloneVoice(_ context.Context, _, _ string) (string, error) { + return "", mvoice.ErrProviderFailed +} From efc25c6a7f8b0638c7b09f4a6def57334e8a6e40 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 26 Oct 2026 12:57:35 +0800 Subject: [PATCH 2/6] =?UTF-8?q?feat(voice):=20emotionTTS=20=E5=8F=91?= =?UTF-8?q?=E9=9F=B3=E4=BA=BA=E7=BB=91=E5=AE=9A=E4=BC=9A=E8=AF=9D=E4=BA=BA?= =?UTF-8?q?=E6=A0=BC=EF=BC=88=E4=B8=8D=E5=86=8D=E5=85=A8=E5=B1=80=E5=AE=9A?= =?UTF-8?q?=E6=AD=BB=E4=B8=80=E4=B8=AA=20character=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主人 2026-10-26 逐字:「TTS 的人物选择是要绑定后端的角色的,不然会出现以后换了 别的角色(人格配置),还是用老角色的 TTS 角色」——上一版把 character 写在面板文件 里当全局值,换人格后声音不变,是真缺陷。 配置形状(单一真源 = $DATA_DIR/config/voice-tts.json): {"enabled":true, "personas":{"":""}, "default":"", // 只服务「解析不到 personaId」 "format":"wav","timeoutSec":60} - 旧的全局 `character` 键**废弃并被忽略**(启动 WARN 提示改用 personas); Config.Normalized 不给任何角色兜底(代码里写死默认角色=病害本身); - configs/personas/*.yaml 不参与(塔裁定:避免启动期静态配置与面板两处漂移); - 零新 .env 键。 选角四条分支(Config.Resolve,纯函数、无缓存): ① persona 有映射 ⇒ 用它; ② personaId 为空/解析不到 + 配了 default ⇒ 用 default; ③ personaId 为空 + 没配 default ⇒ 不合成(ErrNoCharacter,上游 0 请求); ④ persona 无映射(含映射值为空)⇒ 拒绝(ErrPersonaUnmapped), **绝不**拿 default 或别的角色顶 —— 主人担心的病害本身。 两条既有出站口各自按当次请求解析 persona(不在 SetVoice 时算一次缓存成全局): - WS voice_output:用本回合的 env.PersonaID(runVoiceTurn/runVoiceTurnDirect), 经 voiceReplySink 传给 synthesizeVoiceReply,再 WithPersonaID 交给语音面; - POST /api/v1/audio/speech:请求体新增**可选** sessionId(纯增量), 经 turnEnvResolver.sessionPersona 解析;省略 ⇒ 走 default 分支。 Client.Speech 改为**每次传入 character**(内部不缓存发音人);Provider 适配同样按 ctx 的 personaId 现算。选不到发音人时**不回落既有链**(回落=用另一个声音说话), 上游拒绝(404/500)仍按既有口径回落既有链+WARN。 测试:四条分支各自可变异打红,断言打在「上游请求体里的 voice」与帧/响应字节上; 含「同一进程先后两个 persona,第二次绝不沿用第一次 voice」的回归锁, 以及两条出站口的端到端 persona 切换。四条变异(未映射回落 default / 客户端缓存 voice / REST 不看会话 / 选不到时回落底链)均已实测打红后还原。 go build ./... / go vet ./... / go test -race 全绿;真机 live 证据: cyrene(char_84fb88a3)=254630 B、furina(char_5314489e)=213392 B。 --- cmd/cyrene/wire_voice_tts.go | 22 +- cmd/cyrene/wire_voice_tts_test.go | 38 +- internal/config/runtimeconfig/voicetts.go | 39 +- .../config/runtimeconfig/voicetts_test.go | 30 +- internal/runtime/voice/tts/client.go | 24 +- internal/runtime/voice/tts/client_test.go | 470 +++++++++--------- internal/runtime/voice/tts/config.go | 49 +- internal/runtime/voice/tts/live_test.go | 68 ++- internal/runtime/voice/tts/provider.go | 11 +- internal/runtime/voice/tts/selection.go | 107 ++++ internal/runtime/voice/tts/selection_test.go | 114 +++++ internal/runtime/voice/tts/service.go | 57 ++- internal/runtime/voice/tts/service_test.go | 244 +++++++-- internal/services/gateway/audio.go | 11 + internal/services/gateway/voice.go | 24 +- .../services/gateway/voice_emotiontts_test.go | 405 +++++++++------ 16 files changed, 1210 insertions(+), 503 deletions(-) create mode 100644 internal/runtime/voice/tts/selection.go create mode 100644 internal/runtime/voice/tts/selection_test.go diff --git a/cmd/cyrene/wire_voice_tts.go b/cmd/cyrene/wire_voice_tts.go index b282288..4430c8b 100644 --- a/cmd/cyrene/wire_voice_tts.go +++ b/cmd/cyrene/wire_voice_tts.go @@ -43,7 +43,8 @@ func (a *app) voiceForGateway() gateway.VoiceService { service := voicetts.NewService(client, a.voice, voicetts.WithServiceLogger(logger.WithSource(a.log, voiceTTSSource))) logger.WithSource(a.log, voiceTTSSource).Info("emotionTTS voice enabled", "base_url", client.RedactedBaseURL(), - "character", client.Character(), + "personas", len(cfg.Personas), + "default_set", cfg.Default != "", "format", cfg.Format, "timeout_sec", int(cfg.Timeout/time.Second), "config", runtimeconfig.VoiceTTSConfigRelPath) @@ -54,6 +55,8 @@ func (a *app) voiceForGateway() gateway.VoiceService { // 启用"——坏文件、缺文件、显式 false 都得出 false(并在坏文件时留一条 WARN)。 // // 它**不**在这里做"缺省填什么"的裁决:默认值在 `voicetts.Config.Normalized` 一处。 +// 🔴 它也**不**给 personas/default 补任何角色:发音人只能由面板显式给出(全局定死 +// 一个 character 正是主人点名的病害)。 func (a *app) voiceTTSConfig() (voicetts.Config, bool) { log := logger.WithSource(a.log, voiceTTSSource) path := runtimeconfig.DefaultVoiceTTSPath(resolveDataDir(a.cfg)) @@ -70,12 +73,19 @@ func (a *app) voiceTTSConfig() (voicetts.Config, bool) { "path", runtimeconfig.VoiceTTSConfigRelPath, "hint", "set {\"enabled\": true}") return voicetts.Config{}, false } + if legacy := tuning.LegacyGlobalCharacter(); legacy != "" { + // 旧版的全局 character:**忽略**,但绝不静默 —— 运维需要知道换成 personas。 + log.Warn("voice tts config: global `character` is deprecated and ignored", + "character", legacy, "config", runtimeconfig.VoiceTTSConfigRelPath, + "hint", "use {\"personas\": {\"\": \"\"}} so the voice follows the persona") + } return voicetts.Config{ - Enabled: true, - BaseURL: tuning.BaseURL, - Character: tuning.Character, - Format: tuning.Format, - Timeout: time.Duration(tuning.TimeoutSec) * time.Second, + Enabled: true, + BaseURL: tuning.BaseURL, + Personas: tuning.Personas, + Default: tuning.Default, + Format: tuning.Format, + Timeout: time.Duration(tuning.TimeoutSec) * time.Second, }, true } diff --git a/cmd/cyrene/wire_voice_tts_test.go b/cmd/cyrene/wire_voice_tts_test.go index 77115c3..c1d04d8 100644 --- a/cmd/cyrene/wire_voice_tts_test.go +++ b/cmd/cyrene/wire_voice_tts_test.go @@ -40,21 +40,45 @@ func TestVoiceForGateway_DisabledByDefault(t *testing.T) { } // TestVoiceForGateway_WrapsWhenEnabled 钉住"文件启用 ⇒ 门面生效 + 配置逐项读对"。 +// +// 🔴 它同时钉住主人 2026-10-26 那条硬要求在**组合根**上的形态:配置里读出来的是 +// `personas` 映射与 `default`,**没有**任何"全局一个 character"。 func TestVoiceForGateway_WrapsWhenEnabled(t *testing.T) { a := voiceTTSApp(t) writeVoiceTTSPanel(t, a, `{"enabled":true,"baseUrl":"http://127.0.0.1:9880", - "character":"Cyrene","format":"wav","timeoutSec":45}`) + "personas":{"cyrene":"char_84fb88a3","furina":"char_5314489e"}, + "default":"char_84fb88a3","format":"wav","timeoutSec":45}`) + + cfg, enabled := a.voiceTTSConfig() + if !enabled { + t.Fatal("面板写了 enabled:true,但组合根报未启用") + } + if len(cfg.Personas) != 2 || cfg.Personas["furina"] != "char_5314489e" { + t.Fatalf("personas 没有逐项读出: %+v", cfg.Personas) + } + if cfg.Default != "char_84fb88a3" { + t.Fatalf("default = %q, want char_84fb88a3", cfg.Default) + } got := a.voiceForGateway() - svc, ok := got.(*voicetts.Service) - if !ok { + if _, ok := got.(*voicetts.Service); !ok { t.Fatalf("启用后应返回 *tts.Service, got %T", got) } - if !svc.PrimaryActive() { - t.Fatal("面板写了 enabled:true,但这一级没启用") +} + +// TestVoiceForGateway_LegacyCharacterIgnored 钉住已废弃的全局 character **不生效**: +// 只写旧键时,映射与 default 都是空的 ⇒ 任何请求都选不到发音人(不会悄悄回退到 +// 那个全局角色,因为那正是主人担心的病害)。 +func TestVoiceForGateway_LegacyCharacterIgnored(t *testing.T) { + a := voiceTTSApp(t) + writeVoiceTTSPanel(t, a, `{"enabled":true,"character":"char_84fb88a3"}`) + + cfg, enabled := a.voiceTTSConfig() + if !enabled { + t.Fatal("enabled:true 应让这一级启用") } - if svc.Character() != "Cyrene" { - t.Errorf("character = %q, want Cyrene(面板值)", svc.Character()) + if len(cfg.Personas) != 0 || cfg.Default != "" { + t.Fatalf("废弃的全局 character 被当成了发音人: personas=%v default=%q", cfg.Personas, cfg.Default) } } diff --git a/internal/config/runtimeconfig/voicetts.go b/internal/config/runtimeconfig/voicetts.go index fdd0890..fc4e4f5 100644 --- a/internal/config/runtimeconfig/voicetts.go +++ b/internal/config/runtimeconfig/voicetts.go @@ -2,9 +2,16 @@ // `$DATA_DIR/config/voice-tts.json`(design/13 §2.1「运行期生成的配置进 // `$DATA_DIR/config/`;调优/门槛/上限/开关类参数走这里 + 代码默认值」)。 // -// 为什么需要它:把半云端的 emotionTTS 接成她的一级发音人,需要三个运维旋钮 —— -// 开关、地址、角色;而 `.env` 已被主人冻结(**零新 env 键**,design/13 §2.1), -// 所以它们落在这里,与 `memory-tidy.json` / `backthink.json` 同一家族口径。 +// 为什么需要它:把半云端的 emotionTTS 接成她的一级发音人,需要几项运维旋钮 —— +// 开关、地址、**角色映射**、格式、超时;而 `.env` 已被主人冻结(**零新 env 键**, +// design/13 §2.1),所以它们落在这里,与 `memory-tidy.json` / `backthink.json` +// 同一家族口径。 +// +// 🔴 **发音人必须绑定人格**(主人 2026-10-26 逐字:「TTS 的人物选择是要绑定后端的 +// 角色的,不然会出现以后换了别的角色(人格配置),还是用老角色的 TTS 角色」): +// 因此本文件的核心是 `personas`(personaId → characterId)与 `default`(**仅** +// 服务"解析不到 personaId")。全局 `character` 键已废弃并被忽略(见字段注释)。 +// 映射的**唯一真源**就是本文件 —— `configs/personas/*.yaml` 不参与。 // // 🔴 **刻意没有 speed / emotion / tone 三键**(塔 2026-10-26 裁决,实测证据): // - 上游把下游载荷的 `speed` **硬编码 1.0**(`core/utils/llm_provider.py` 的 @@ -66,8 +73,26 @@ type VoiceTTSTuning struct { // 🔴 该服务**无鉴权、只绑回环**:这里填公网地址等于把一个无鉴权的合成口暴露给 // 网络,安全上由部署方自行负责(本文件不替人做这个决定,也不给默认值以外的暗示)。 BaseURL string `json:"baseUrl,omitempty"` - // Character 是角色(服务端 `/api/characters` 的 id 或名字)。 - // 空 = 代码默认 `char_84fb88a3`(主人指定的 Cyrene)。 + // Personas 是 **personaId → 上游 characterId** 的唯一映射源(主人 2026-10-26: + // 「TTS 的人物选择是要绑定后端的角色的」)。 + // + // - 这是**唯一真源**:`configs/personas/*.yaml` 不参与(启动期静态配置 + 面板 + // 可热调,两处必然漂移)。 + // - 有键但值为空串 = 该角色明确没有发音人 ⇒ 不合成(绝不借用别的角色)。 + // - **没有这个键** ⇒ 同样不合成,且**不**回落到下面的 `default`。 + Personas map[string]string `json:"personas,omitempty"` + // Default 是**仅当 personaId 解析不到**(会话无绑定 / 读取失败 / 该出站口本身 + // 没有会话)时使用的发音人;空 = 没配 ⇒ 那种情况**不合成**。 + // + // 🔴 它**不服务**「配了 personas 但没有这个 persona 的键」——那种情况一律不合成 + // (见 `tts.Config.Resolve` 的拒绝分支)。用 default 去顶正是"换了人格还用老角色 + // 声音"的病害本身。 + Default string `json:"default,omitempty"` + // Character 是**已废弃**的全局发音人键(切片第一版)。 + // + // 🔴 它被**忽略**:全局一个 character 正是主人点名的缺陷(换人格仍用老声音)。 + // 保留字段只为在启动日志里明确告诉运维"这一键不再生效,请改用 personas", + // 而不是让它被 json 静默丢弃。新配置**不要**写它。 Character string `json:"character,omitempty"` // Format 是请求格式,只接受 wav/mp3/ogg。空 = 代码默认 wav。 // @@ -84,6 +109,10 @@ type VoiceTTSTuning struct { // EnabledOn 报告这一级是否被显式打开(nil = 没配 ⇒ 不启用)。 func (t VoiceTTSTuning) EnabledOn() bool { return t.Enabled != nil && *t.Enabled } +// LegacyGlobalCharacter 返回已废弃的全局 `character` 键(未写时为空)。调用方应 +// 记一条 WARN 说明"这一键不再生效,改用 personas",**绝不**把它当发音人使用。 +func (t VoiceTTSTuning) LegacyGlobalCharacter() string { return strings.TrimSpace(t.Character) } + // DefaultVoiceTTSPath 返回落点:`$DATA_DIR/config/voice-tts.json`。 func DefaultVoiceTTSPath(dataDir string) string { return filepath.Join(dataDir, voiceTTSConfigSubdir, voiceTTSFileName) diff --git a/internal/config/runtimeconfig/voicetts_test.go b/internal/config/runtimeconfig/voicetts_test.go index a014f72..107393b 100644 --- a/internal/config/runtimeconfig/voicetts_test.go +++ b/internal/config/runtimeconfig/voicetts_test.go @@ -26,9 +26,10 @@ func TestLoadVoiceTTSTuning(t *testing.T) { t.Fatalf("空白: tuning=%+v err=%v, want 零值 + nil", tuning, err) } - // ③ 合法 - body := `{"enabled":true,"baseUrl":"http://127.0.0.1:9880","character":"char_84fb88a3", - "format":"wav","timeoutSec":45}` + // ③ 合法(新形状:personas 映射 + default;旧的全局 character 不再参与) + body := `{"enabled":true,"baseUrl":"http://127.0.0.1:9880", + "personas":{"cyrene":"char_84fb88a3","furina":"char_5314489e"}, + "default":"char_84fb88a3","format":"wav","timeoutSec":45}` if err := os.WriteFile(path, []byte(body), 0o600); err != nil { t.Fatalf("write: %v", err) } @@ -36,9 +37,30 @@ func TestLoadVoiceTTSTuning(t *testing.T) { if err != nil { t.Fatalf("合法文件: %v", err) } - if !tuning.EnabledOn() || tuning.Character != "char_84fb88a3" || tuning.Format != "wav" || tuning.TimeoutSec != 45 { + if !tuning.EnabledOn() || tuning.Format != "wav" || tuning.TimeoutSec != 45 { t.Fatalf("读取结果不对: %+v", tuning) } + if len(tuning.Personas) != 2 || tuning.Personas["furina"] != "char_5314489e" { + t.Fatalf("personas 没有被读出: %+v", tuning.Personas) + } + if tuning.Default != "char_84fb88a3" { + t.Fatalf("default = %q", tuning.Default) + } + + // ③b 旧的全局 character 仍能读出来(供启动日志提示"已废弃"),但它**不是** + // personas/default 的一部分 —— 组合根绝不把它当发音人。 + if err := os.WriteFile(path, []byte(`{"enabled":true,"character":"char_old"}`), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + if tuning, err = LoadVoiceTTSTuning(path); err != nil { + t.Fatalf("旧键: %v", err) + } + if tuning.LegacyGlobalCharacter() != "char_old" { + t.Fatalf("LegacyGlobalCharacter = %q", tuning.LegacyGlobalCharacter()) + } + if len(tuning.Personas) != 0 || tuning.Default != "" { + t.Fatalf("旧全局 character 混进了 personas/default: %+v", tuning) + } // ④ 显式 false 与"没配"同效,但指针让它在读取侧可区分。 if err := os.WriteFile(path, []byte(`{"enabled":false}`), 0o600); err != nil { diff --git a/internal/runtime/voice/tts/client.go b/internal/runtime/voice/tts/client.go index 79a55d4..94b0e6e 100644 --- a/internal/runtime/voice/tts/client.go +++ b/internal/runtime/voice/tts/client.go @@ -123,9 +123,6 @@ func (c *Client) Config() Config { return c.cfg } // Enabled 报告这一级是否启用。 func (c *Client) Enabled() bool { return c.cfg.Enabled } -// Character 返回生效角色(默认 `char_84fb88a3`)。 -func (c *Client) Character() string { return c.cfg.Character } - // RedactedBaseURL 返回**可安全打日志**的服务地址(只保留 scheme 与 host —— // 运维若在地址里塞了凭据,日志不该把它写出来)。 func (c *Client) RedactedBaseURL() string { return redactURL(c.cfg.BaseURL) } @@ -135,20 +132,29 @@ func (c *Client) Stats() Stats { return Stats{Attempts: c.attempts.Load(), Failures: c.failures.Load()} } -// Speech 把文本合成成音频。这是本包的**唯一网络出口**:`Service`(降级包装)与 -// 既有语音链的 `TTS` 形状都走它,绝不存在第二处 HTTP 调用。 +// Speech 把文本用**指定发音人**合成成音频。这是本包的**唯一网络出口**: +// `Service`(降级包装)与既有语音链的 `TTS` 形状都走它,绝不存在第二处 HTTP 调用。 +// +// `character` 是**每次调用显式传入**的,客户端内部**不缓存**任何发音人 —— 因此 +// 同一进程先后服务两个角色,第二次绝不会沿用第一次的 voice(主人 2026-10-26 的 +// 硬要求;由 `Service` 按每次请求的 personaId 现算,见 selection.go)。 +// 空 character 一律拒绝:宁可没有声音,也不要用一个"默认"角色的声音冒充她。 // // 返回值里的 `Format` 是**字节嗅探**所得,不是请求里那个 `response_format`。 -func (c *Client) Speech(ctx context.Context, text string) (Audio, error) { +func (c *Client) Speech(ctx context.Context, character, text string) (Audio, error) { if !c.cfg.Enabled { return Audio{}, ErrDisabled } + voice := strings.TrimSpace(character) + if voice == "" { + return Audio{}, ErrNoCharacter + } trimmed := strings.TrimSpace(text) if trimmed == "" { return Audio{}, ErrEmptyText } c.attempts.Add(1) - audi, err := c.speak(ctx, trimmed) + audi, err := c.speak(ctx, voice, trimmed) if err != nil { c.failures.Add(1) } @@ -156,8 +162,8 @@ func (c *Client) Speech(ctx context.Context, text string) (Audio, error) { } // speak 是 Speech 的主体:编码 → 发送 → 判状态 → 嗅探。失败原因已分类。 -func (c *Client) speak(ctx context.Context, text string) (Audio, error) { - body, err := encodeSpeech(text, c.cfg.Character, c.cfg.Format) +func (c *Client) speak(ctx context.Context, character, text string) (Audio, error) { + body, err := encodeSpeech(text, character, c.cfg.Format) if err != nil { return Audio{}, err } diff --git a/internal/runtime/voice/tts/client_test.go b/internal/runtime/voice/tts/client_test.go index 3d4dfe4..7d9e7f3 100644 --- a/internal/runtime/voice/tts/client_test.go +++ b/internal/runtime/voice/tts/client_test.go @@ -1,230 +1,240 @@ -package tts - -// 本文件锁定 `Client` 的五条对外口径(每条都能被"改坏即打红"): -// -// 1. 🔴 **中文正文必须以 UTF-8 送出**(塔实测:GBK 字节会让服务端回 -// `400 {"detail":"There was an error parsing the body"}`)。用例既断言服务端能按 -// UTF-8 解出**逐字相等**的正文,也断言请求字节里能直接搜到那段中文 —— 后者是 -// 把"改成字符串拼接/转码"这类改动的代价变成红灯的钉子。 -// 2. 非 200 ⇒ `ErrUpstream`(且**错误里不带正文**)。 -// 3. 超时 ⇒ `ErrUnreachable` 且保留 `context.DeadlineExceeded`(可 errors.Is)。 -// 4. 200 但不是音频 ⇒ `ErrNotAudio`(绝不把 JSON/HTML 错误页当音频下发)。 -// 5. 未启用 / 空文本 / 超限各自有可分类的哨兵。 - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "io" - "net/http" - "net/http/httptest" - "strings" - "sync/atomic" - "testing" - "time" -) - -// chineseSpeech 是塔实测那台服务能合成的那句话(保留全角标点,逐字比对)。 -const chineseSpeech = "你好,我是苏梦。今天心情特别好,我们一起出去玩吧!" - -// wavBytes 造一段**魔数正确**的假 WAV(嗅探只看头,内容是什么不影响判据)。 -func wavBytes(payload string) []byte { - b := make([]byte, 0, 44+len(payload)) - b = append(b, []byte("RIFF")...) - b = append(b, 0, 0, 0, 0) - b = append(b, []byte("WAVEfmt ")...) - b = append(b, bytes.Repeat([]byte{0}, 16)...) - b = append(b, []byte("data")...) - b = append(b, 0, 0, 0, 0) - return append(b, []byte(payload)...) -} - -// newTestClient 指向一个 httptest 服务;可变选项留给各用例。 -func newTestClient(t *testing.T, handler http.HandlerFunc, opts ...Option) *Client { - t.Helper() - srv := httptest.NewServer(handler) - t.Cleanup(srv.Close) - base := []Option{WithLogger(discardLogger())} - base = append(base, opts...) - return New(Config{Enabled: true, BaseURL: srv.URL, Timeout: 2 * time.Second}, base...) -} - -// TestSpeechSendsUTF8ChineseText 是**本切片最重要的一条**:中文正文必须按 UTF-8 -// 字节、以 `application/json` 送出,且字段名/取值与上游 openapi 逐字一致。 -func TestSpeechSendsUTF8ChineseText(t *testing.T) { - var gotPayload speechPayload - var gotRaw []byte - var gotCT string - client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { - gotCT = r.Header.Get("Content-Type") - gotRaw, _ = io.ReadAll(r.Body) - if err := json.Unmarshal(gotRaw, &gotPayload); err != nil { - t.Errorf("请求体不是合法 JSON(说明有人改成了拼接/转码): %v", err) - } - w.Header().Set("Content-Type", "audio/wav") - _, _ = w.Write(wavBytes("ok")) - }) - - audio, err := client.Speech(context.Background(), chineseSpeech) - if err != nil { - t.Fatalf("Speech: %v", err) - } - if gotCT != "application/json" { - t.Errorf("Content-Type = %q, want application/json", gotCT) - } - // ① 服务端按 UTF-8 解出来必须**逐字相等**(GBK 字节在这里必然对不上)。 - if gotPayload.Input != chineseSpeech { - t.Fatalf("input = %q, want %q", gotPayload.Input, chineseSpeech) - } - // ② 请求字节里必须能直接搜到那段中文的 UTF-8 编码 —— 一旦有人改成 GBK 转码, - // 这里立刻红(UTF-8 的"你好"= E4BD A0 E5A5 BD)。 - if !bytes.Contains(gotRaw, []byte(chineseSpeech)) { - t.Fatalf("请求体里找不到 UTF-8 正文(字节被转码了?): %q", string(gotRaw)) - } - if !bytes.Contains(gotRaw, []byte("\xe4\xbd\xa0\xe5\xa5\xbd")) { - t.Fatalf("请求体里找不到「你好」的 UTF-8 字节") - } - if gotPayload.Model != modelName { - t.Errorf("model = %q, want %q", gotPayload.Model, modelName) - } - if gotPayload.Voice != DefaultCharacter { - t.Errorf("voice = %q, want %q", gotPayload.Voice, DefaultCharacter) - } - if gotPayload.ResponseFormat != DefaultFormat { - t.Errorf("response_format = %q, want %q", gotPayload.ResponseFormat, DefaultFormat) - } - // ③ 回发的格式由**字节嗅探**裁决,不是请求里那个字段。 - if audio.Format != "wav" || len(audio.Data) == 0 { - t.Fatalf("audio = %+v, want wav with bytes", audio) - } -} - -// TestSpeechHTMLEscapingDisabled 钉住"正文逐字不变":`<`/`>`/`&` 不得被改写成 \u003c。 -func TestSpeechHTMLEscapingDisabled(t *testing.T) { - var gotRaw []byte - client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { - gotRaw, _ = io.ReadAll(r.Body) - _, _ = w.Write(wavBytes("ok")) - }) - if _, err := client.Speech(context.Background(), "ad"); err != nil { - t.Fatalf("Speech: %v", err) - } - if bytes.Contains(gotRaw, []byte(`\u003c`)) || bytes.Contains(gotRaw, []byte(`\u0026`)) { - t.Fatalf("正文被 HTML 转义了: %q", string(gotRaw)) - } -} - -// TestSpeechRejectsNon200 钉住上游拒绝:可分类、带状态码、**不带正文**。 -func TestSpeechRejectsNon200(t *testing.T) { - client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"detail":"There was an error parsing the body"}`)) - }) - _, err := client.Speech(context.Background(), chineseSpeech) - if !errors.Is(err, ErrUpstream) { - t.Fatalf("err = %v, want ErrUpstream", err) - } - if !strings.Contains(err.Error(), "400") { - t.Errorf("错误里应带状态码: %v", err) - } - if strings.Contains(err.Error(), "苏梦") { - t.Errorf("错误链里出现了正文(红线:日志/错误不带正文): %v", err) - } - if got := client.Stats(); got.Attempts != 1 || got.Failures != 1 { - t.Errorf("stats = %+v, want 1 attempt / 1 failure", got) - } -} - -// TestSpeechTimeout 钉住超时:归 ErrUnreachable,且因果链仍能判出 deadline。 -func TestSpeechTimeout(t *testing.T) { - release := make(chan struct{}) - t.Cleanup(func() { close(release) }) - handler := func(w http.ResponseWriter, r *http.Request) { - select { - case <-release: - case <-time.After(2 * time.Second): - } - _, _ = w.Write(wavBytes("late")) - } - client := newTestClient(t, handler, WithHTTPClient(&http.Client{Timeout: 60 * time.Millisecond})) - start := time.Now() - _, err := client.Speech(context.Background(), chineseSpeech) - if !errors.Is(err, ErrUnreachable) { - t.Fatalf("err = %v, want ErrUnreachable", err) - } - if !errors.Is(err, context.DeadlineExceeded) { - t.Errorf("err = %v, want wraps context.DeadlineExceeded", err) - } - if elapsed := time.Since(start); elapsed > time.Second { - t.Errorf("超时没有生效(用了 %v)", elapsed) - } -} - -// TestSpeechRejectsNonAudio 钉住"200 但不是音频":JSON 错误页绝不当音频。 -func TestSpeechRejectsNonAudio(t *testing.T) { - client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"status":"success","audio_url":"/outputs/x.wav"}`)) - }) - _, err := client.Speech(context.Background(), chineseSpeech) - if !errors.Is(err, ErrNotAudio) { - t.Fatalf("err = %v, want ErrNotAudio", err) - } - if got := client.Stats(); got.Failures != 1 { - t.Errorf("stats = %+v, want 1 failure", got) - } -} - -// TestSpeechRejectsOversizeResponse 钉住体积上限。 -func TestSpeechRejectsOversizeResponse(t *testing.T) { - client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write(wavBytes(strings.Repeat("x", 128))) - }, WithMaxResponseBytes(32)) - _, err := client.Speech(context.Background(), chineseSpeech) - if !errors.Is(err, ErrTooLarge) { - t.Fatalf("err = %v, want ErrTooLarge", err) - } -} - -// TestSpeechGuards 钉住"不该发网络"的两条前置拒绝,以及"未启用"的可区分性。 -func TestSpeechGuards(t *testing.T) { - var hits atomic.Int64 - client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { - hits.Add(1) - _, _ = w.Write(wavBytes("ok")) - }) - - if _, err := client.Speech(context.Background(), " "); !errors.Is(err, ErrEmptyText) { - t.Errorf("空文本 err = %v, want ErrEmptyText", err) - } - disabled := New(Config{Enabled: false, BaseURL: client.cfg.BaseURL}, WithLogger(discardLogger())) - if _, err := disabled.Speech(context.Background(), chineseSpeech); !errors.Is(err, ErrDisabled) { - t.Errorf("未启用 err = %v, want ErrDisabled", err) - } - // 被拒的两次都不该打网络;未启用也不该被计成"失败"。 - if got := hits.Load(); got != 0 { - t.Errorf("上游被调用 %d 次,want 0", got) - } - if got := disabled.Stats(); got.Attempts != 0 || got.Failures != 0 { - t.Errorf("未启用却计了数: %+v", got) - } -} - -// TestConfigNormalized 钉住默认值与非法格式的回落(不让一个写错的格式关掉语音面)。 -func TestConfigNormalized(t *testing.T) { - got := Config{}.Normalized(discardLogger()) - if got.Enabled { - t.Errorf("零值配置不该被悄悄启用") - } - if got.BaseURL != DefaultBaseURL || got.Character != DefaultCharacter || - got.Format != DefaultFormat || got.Timeout != DefaultTimeout { - t.Fatalf("默认值不对: %+v", got) - } - if custom := (Config{BaseURL: " http://127.0.0.1:9880/ ", Format: "MP3"}).Normalized(discardLogger()); custom.BaseURL != DefaultBaseURL || custom.Format != "mp3" { - t.Errorf("归一化不对: %+v", custom) - } - if bad := (Config{Format: "exe"}).Normalized(discardLogger()); bad.Format != DefaultFormat { - t.Errorf("非法格式应回落默认: %+v", bad) - } -} +package tts + +// 本文件锁定 `Client` 的五条对外口径(每条都能被"改坏即打红"): +// +// 1. 🔴 **中文正文必须以 UTF-8 送出**(塔实测:GBK 字节会让服务端回 +// `400 {"detail":"There was an error parsing the body"}`)。用例既断言服务端能按 +// UTF-8 解出**逐字相等**的正文,也断言请求字节里能直接搜到那段中文 —— 后者是 +// 把"改成字符串拼接/转码"这类改动的代价变成红灯的钉子。 +// 2. 非 200 ⇒ `ErrUpstream`(且**错误里不带正文**)。 +// 3. 超时 ⇒ `ErrUnreachable` 且保留 `context.DeadlineExceeded`(可 errors.Is)。 +// 4. 200 但不是音频 ⇒ `ErrNotAudio`(绝不把 JSON/HTML 错误页当音频下发)。 +// 5. 未启用 / 空文本 / 超限各自有可分类的哨兵。 + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" +) + +// chineseSpeech 是塔实测那台服务能合成的那句话(保留全角标点,逐字比对)。 +const chineseSpeech = "你好,我是苏梦。今天心情特别好,我们一起出去玩吧!" + +// wavBytes 造一段**魔数正确**的假 WAV(嗅探只看头,内容是什么不影响判据)。 +func wavBytes(payload string) []byte { + b := make([]byte, 0, 44+len(payload)) + b = append(b, []byte("RIFF")...) + b = append(b, 0, 0, 0, 0) + b = append(b, []byte("WAVEfmt ")...) + b = append(b, bytes.Repeat([]byte{0}, 16)...) + b = append(b, []byte("data")...) + b = append(b, 0, 0, 0, 0) + return append(b, []byte(payload)...) +} + +// newTestClient 指向一个 httptest 服务;可变选项留给各用例。 +func newTestClient(t *testing.T, handler http.HandlerFunc, opts ...Option) *Client { + t.Helper() + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + base := []Option{WithLogger(discardLogger())} + base = append(base, opts...) + return New(Config{Enabled: true, BaseURL: srv.URL, Timeout: 2 * time.Second}, base...) +} + +// TestSpeechSendsUTF8ChineseText 是**本切片最重要的一条**:中文正文必须按 UTF-8 +// 字节、以 `application/json` 送出,且字段名/取值与上游 openapi 逐字一致。 +func TestSpeechSendsUTF8ChineseText(t *testing.T) { + var gotPayload speechPayload + var gotRaw []byte + var gotCT string + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + gotCT = r.Header.Get("Content-Type") + gotRaw, _ = io.ReadAll(r.Body) + if err := json.Unmarshal(gotRaw, &gotPayload); err != nil { + t.Errorf("请求体不是合法 JSON(说明有人改成了拼接/转码): %v", err) + } + w.Header().Set("Content-Type", "audio/wav") + _, _ = w.Write(wavBytes("ok")) + }) + + audio, err := client.Speech(context.Background(), DefaultCharacter, chineseSpeech) + if err != nil { + t.Fatalf("Speech: %v", err) + } + if gotCT != "application/json" { + t.Errorf("Content-Type = %q, want application/json", gotCT) + } + // ① 服务端按 UTF-8 解出来必须**逐字相等**(GBK 字节在这里必然对不上)。 + if gotPayload.Input != chineseSpeech { + t.Fatalf("input = %q, want %q", gotPayload.Input, chineseSpeech) + } + // ② 请求字节里必须能直接搜到那段中文的 UTF-8 编码 —— 一旦有人改成 GBK 转码, + // 这里立刻红(UTF-8 的"你好"= E4BD A0 E5A5 BD)。 + if !bytes.Contains(gotRaw, []byte(chineseSpeech)) { + t.Fatalf("请求体里找不到 UTF-8 正文(字节被转码了?): %q", string(gotRaw)) + } + if !bytes.Contains(gotRaw, []byte("\xe4\xbd\xa0\xe5\xa5\xbd")) { + t.Fatalf("请求体里找不到「你好」的 UTF-8 字节") + } + if gotPayload.Model != modelName { + t.Errorf("model = %q, want %q", gotPayload.Model, modelName) + } + if gotPayload.Voice != DefaultCharacter { + t.Errorf("voice = %q, want %q", gotPayload.Voice, DefaultCharacter) + } + if gotPayload.ResponseFormat != DefaultFormat { + t.Errorf("response_format = %q, want %q", gotPayload.ResponseFormat, DefaultFormat) + } + // ③ 回发的格式由**字节嗅探**裁决,不是请求里那个字段。 + if audio.Format != "wav" || len(audio.Data) == 0 { + t.Fatalf("audio = %+v, want wav with bytes", audio) + } +} + +// TestSpeechHTMLEscapingDisabled 钉住"正文逐字不变":`<`/`>`/`&` 不得被改写成 \u003c。 +func TestSpeechHTMLEscapingDisabled(t *testing.T) { + var gotRaw []byte + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + gotRaw, _ = io.ReadAll(r.Body) + _, _ = w.Write(wavBytes("ok")) + }) + if _, err := client.Speech(context.Background(), DefaultCharacter, "ad"); err != nil { + t.Fatalf("Speech: %v", err) + } + if bytes.Contains(gotRaw, []byte(`\u003c`)) || bytes.Contains(gotRaw, []byte(`\u0026`)) { + t.Fatalf("正文被 HTML 转义了: %q", string(gotRaw)) + } +} + +// TestSpeechRejectsNon200 钉住上游拒绝:可分类、带状态码、**不带正文**。 +func TestSpeechRejectsNon200(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"detail":"There was an error parsing the body"}`)) + }) + _, err := client.Speech(context.Background(), DefaultCharacter, chineseSpeech) + if !errors.Is(err, ErrUpstream) { + t.Fatalf("err = %v, want ErrUpstream", err) + } + if !strings.Contains(err.Error(), "400") { + t.Errorf("错误里应带状态码: %v", err) + } + if strings.Contains(err.Error(), "苏梦") { + t.Errorf("错误链里出现了正文(红线:日志/错误不带正文): %v", err) + } + if got := client.Stats(); got.Attempts != 1 || got.Failures != 1 { + t.Errorf("stats = %+v, want 1 attempt / 1 failure", got) + } +} + +// TestSpeechTimeout 钉住超时:归 ErrUnreachable,且因果链仍能判出 deadline。 +func TestSpeechTimeout(t *testing.T) { + release := make(chan struct{}) + t.Cleanup(func() { close(release) }) + handler := func(w http.ResponseWriter, r *http.Request) { + select { + case <-release: + case <-time.After(2 * time.Second): + } + _, _ = w.Write(wavBytes("late")) + } + client := newTestClient(t, handler, WithHTTPClient(&http.Client{Timeout: 60 * time.Millisecond})) + start := time.Now() + _, err := client.Speech(context.Background(), DefaultCharacter, chineseSpeech) + if !errors.Is(err, ErrUnreachable) { + t.Fatalf("err = %v, want ErrUnreachable", err) + } + if !errors.Is(err, context.DeadlineExceeded) { + t.Errorf("err = %v, want wraps context.DeadlineExceeded", err) + } + if elapsed := time.Since(start); elapsed > time.Second { + t.Errorf("超时没有生效(用了 %v)", elapsed) + } +} + +// TestSpeechRejectsNonAudio 钉住"200 但不是音频":JSON 错误页绝不当音频。 +func TestSpeechRejectsNonAudio(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":"success","audio_url":"/outputs/x.wav"}`)) + }) + _, err := client.Speech(context.Background(), DefaultCharacter, chineseSpeech) + if !errors.Is(err, ErrNotAudio) { + t.Fatalf("err = %v, want ErrNotAudio", err) + } + if got := client.Stats(); got.Failures != 1 { + t.Errorf("stats = %+v, want 1 failure", got) + } +} + +// TestSpeechRejectsOversizeResponse 钉住体积上限。 +func TestSpeechRejectsOversizeResponse(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(wavBytes(strings.Repeat("x", 128))) + }, WithMaxResponseBytes(32)) + _, err := client.Speech(context.Background(), DefaultCharacter, chineseSpeech) + if !errors.Is(err, ErrTooLarge) { + t.Fatalf("err = %v, want ErrTooLarge", err) + } +} + +// TestSpeechGuards 钉住"不该发网络"的两条前置拒绝,以及"未启用"的可区分性。 +func TestSpeechGuards(t *testing.T) { + var hits atomic.Int64 + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + _, _ = w.Write(wavBytes("ok")) + }) + + if _, err := client.Speech(context.Background(), DefaultCharacter, " "); !errors.Is(err, ErrEmptyText) { + t.Errorf("空文本 err = %v, want ErrEmptyText", err) + } + disabled := New(Config{Enabled: false, BaseURL: client.cfg.BaseURL}, WithLogger(discardLogger())) + if _, err := disabled.Speech(context.Background(), DefaultCharacter, chineseSpeech); !errors.Is(err, ErrDisabled) { + t.Errorf("未启用 err = %v, want ErrDisabled", err) + } + // 被拒的两次都不该打网络;未启用也不该被计成"失败"。 + if got := hits.Load(); got != 0 { + t.Errorf("上游被调用 %d 次,want 0", got) + } + if got := disabled.Stats(); got.Attempts != 0 || got.Failures != 0 { + t.Errorf("未启用却计了数: %+v", got) + } +} + +// TestConfigNormalized 钉住默认值与非法格式的回落(不让一个写错的格式关掉语音面)。 +// +// 🔴 它同时钉住一条**反向**口径:归一化**不得**给任何角色兜底 —— 代码里写死一个 +// 默认发音人,正是主人点名的病害(换人格仍用老声音)。 +func TestConfigNormalized(t *testing.T) { + got := Config{}.Normalized(discardLogger()) + if got.Enabled { + t.Errorf("零值配置不该被悄悄启用") + } + if got.BaseURL != DefaultBaseURL || got.Format != DefaultFormat || got.Timeout != DefaultTimeout { + t.Fatalf("默认值不对: %+v", got) + } + if got.Default != "" || len(got.Personas) != 0 { + t.Fatalf("归一化给发音人兜底了(红线:不许有代码默认角色): %+v", got) + } + if custom := (Config{BaseURL: " http://127.0.0.1:9880/ ", Format: "MP3"}).Normalized(discardLogger()); custom.BaseURL != DefaultBaseURL || custom.Format != "mp3" { + t.Errorf("归一化不对: %+v", custom) + } + if bad := (Config{Format: "exe"}).Normalized(discardLogger()); bad.Format != DefaultFormat { + t.Errorf("非法格式应回落默认: %+v", bad) + } + // persona/character 两端的空白被清掉;空 personaId 的键被丢弃。 + trimmed := (Config{Personas: map[string]string{" a ": " char_1 ", "": "char_2"}, Default: " char_3 "}).Normalized(discardLogger()) + if trimmed.Personas["a"] != "char_1" || len(trimmed.Personas) != 1 || trimmed.Default != "char_3" { + t.Fatalf("映射/默认值没有被归一化: %+v", trimmed) + } +} diff --git a/internal/runtime/voice/tts/config.go b/internal/runtime/voice/tts/config.go index 88c48f7..3c68b26 100644 --- a/internal/runtime/voice/tts/config.go +++ b/internal/runtime/voice/tts/config.go @@ -38,8 +38,12 @@ const ( // DefaultBaseURL 是该服务的默认地址(**只绑回环**、无鉴权:绝不是公网地址)。 DefaultBaseURL = "http://127.0.0.1:9880" - // DefaultCharacter 是默认角色 —— 主人指定的「Cyrene」(`GET /api/characters` - // 实测 id = `char_84fb88a3`)。 + // DefaultCharacter 只是**示例/文档用**的角色 id(主人指定的「Cyrene」, + // `GET /api/characters` 实测 id = `char_84fb88a3`)。 + // + // 🔴 它**不是**代码里的兜底发音人:把任何角色 id 写死在代码里,正是主人 + // 2026-10-26 点出的病害(换了人格仍用老角色的声音)。发音人一律由 + // `Config.Personas` / `Config.Default` 显式给出(见 Resolve)。 DefaultCharacter = "char_84fb88a3" // DefaultFormat 是默认请求格式。服务端产物**恒为 WAV**(它把 .wav 落盘后用 @@ -66,14 +70,23 @@ const ( // `$DATA_DIR/config/voice-tts.json` 映射而来;零新 `.env` 键)。 // // 🔴 刻意**没有** speed / emotion / tone 三项,理由见包注释(上游硬编码或自推)。 +// 🔴 也**没有全局 character**:发音人只能由 `Personas`(按会话角色映射)或 +// `Default`(仅服务"解析不到 personaId")给出 —— 主人 2026-10-26 的硬要求 +// 「TTS 的人物选择要绑定后端的角色,不然换了人格还是用老角色的声音」。 type Config struct { // Enabled 是总开关。false = 该级整体不参与(调用方应保留既有链, // 而不是把"没启用"当成"合成失败")。 Enabled bool // BaseURL 是服务地址;空 = DefaultBaseURL。约定只填回环地址。 BaseURL string - // Character 是角色(服务端 `/api/characters` 的 id 或名字);空 = DefaultCharacter。 - Character string + // Personas 是 **personaId → 上游 characterId** 的唯一映射源(也接受 + // `char_` 前缀或不带前缀的写法,见 Resolve)。它有键但值为空 = 该角色 + // 没有可用发音人 ⇒ 不合成(**绝不**借用别的角色的声音)。 + Personas map[string]string + // Default 是**仅当 personaId 解析不到**(空串:会话无绑定 / 读取失败 / + // 该出口本身没有会话)时使用的发音人。它**不**服务"配了 Personas 但没有 + // 这个 persona 的键"那种情况 —— 那是 Resolve 的拒绝分支(主人点名的病害)。 + Default string // Format 是请求格式(`response_format`);空 = DefaultFormat。只接受 // wav/mp3/ogg —— 且**只用于请求**,回发的真实格式一律按字节嗅探。 Format string @@ -88,16 +101,17 @@ var supportedFormats = map[string]bool{"wav": true, "mp3": true, "ogg": true} // Normalized 返回填好默认值、去掉首尾空白的配置。非法 Format 回落到默认值并 // 通过 log 记一条 WARN(**不让它停摆** —— 一个写错的格式不该关掉整个语音面)。 +// +// 它**不**给 `Personas`/`Default` 补任何默认角色:发音人只有两种合法来源 +// (显式映射 / 显式 default),代码里的兜底值就是主人担心的病害本身。 func (c Config) Normalized(log *slog.Logger) Config { out := c out.BaseURL = strings.TrimRight(strings.TrimSpace(out.BaseURL), "/") if out.BaseURL == "" { out.BaseURL = DefaultBaseURL } - out.Character = strings.TrimSpace(out.Character) - if out.Character == "" { - out.Character = DefaultCharacter - } + out.Personas = normalizePersonas(out.Personas) + out.Default = strings.TrimSpace(out.Default) out.Format = strings.ToLower(strings.TrimSpace(out.Format)) if out.Format == "" { out.Format = DefaultFormat @@ -113,3 +127,22 @@ func (c Config) Normalized(log *slog.Logger) Config { } return out } + +// normalizePersonas 去掉映射两端的空白与空键(空键不可能是合法 personaId), +// 并保留"键在、值为空"这一形态 —— 它表示"这个角色明确没有发音人",与"没这个键" +// 在 Resolve 里是**不同**的分支(前者拒绝,后者也拒绝;两者都绝不借用别的角色)。 +func normalizePersonas(in map[string]string) map[string]string { + if len(in) == 0 { + return nil + } + out := make(map[string]string, len(in)) + for persona, character := range in { + key := strings.TrimSpace(persona) + if key == "" { + continue + } + out[key] = strings.TrimSpace(character) + } + return out +} + diff --git a/internal/runtime/voice/tts/live_test.go b/internal/runtime/voice/tts/live_test.go index 3920f18..39f8a9f 100644 --- a/internal/runtime/voice/tts/live_test.go +++ b/internal/runtime/voice/tts/live_test.go @@ -2,6 +2,17 @@ package tts +// 本文件是**对着真的 emotionTTS 服务**的端到端验证,默认不进 `go test ./...` +// (与 `dashscope_live` 同一口径:不得让常规测试去碰外部服务)。 +// +// 显式运行(服务默认在 127.0.0.1:9880,可用 EMOTIONTTS_BASE_URL 覆盖): +// +// go test -tags emotiontts_live -run TestLiveSpeech -v ./internal/runtime/voice/tts/ +// +// 🔴 第二条用例(TestLiveSpeechFollowsPersona)是主人 2026-10-26 那条硬要求的 +// **真机证据**:同一份配置、同一进程,两个 persona 各自合成一次 —— 两次用的是 +// 两个不同的上游角色,音频字节也不同。 + import ( "context" "os" @@ -9,20 +20,53 @@ import ( "time" ) -// 本文件是**对着真的 emotionTTS 服务**的端到端验证,默认不进 `go test ./...` -// (与 `dashscope_live` 同一口径:不得让常规测试去碰外部服务)。 -// -// 显式运行(服务默认在 127.0.0.1:9880,可用 EMOTIONTTS_BASE_URL 覆盖): -// -// go test -tags emotiontts_live -run TestLiveSpeech -v ./internal/runtime/voice/tts/ +// TestLiveSpeech 用默认角色合成一句话,验证拿到的确实是 WAV。 func TestLiveSpeech(t *testing.T) { - baseURL := os.Getenv("EMOTIONTTS_BASE_URL") - client := New(Config{Enabled: true, BaseURL: baseURL, Timeout: 120 * time.Second}) + client := New(Config{Enabled: true, BaseURL: os.Getenv("EMOTIONTTS_BASE_URL"), Timeout: 120 * time.Second}) + audio, elapsed := synthesizeOrFail(t, client, DefaultCharacter) + t.Logf("evidence: base=%s character=%s bytes=%d format=%s elapsed=%s", + client.Config().BaseURL, DefaultCharacter, len(audio.Data), audio.Format, elapsed) +} +// TestLiveSpeechFollowsPersona 是真机的"换人格就换声音"证据:两个 persona 分别映射 +// 到服务端真实存在的两个角色(Cyrene / 芙宁娜),各自合成同一句话。 +func TestLiveSpeechFollowsPersona(t *testing.T) { + cfg := Config{ + Enabled: true, + BaseURL: os.Getenv("EMOTIONTTS_BASE_URL"), + Timeout: 120 * time.Second, + Personas: map[string]string{ + "cyrene": "char_84fb88a3", + "furina": "char_5314489e", + }, + } + client := New(cfg) + sizes := make(map[string]int, 2) + for _, persona := range []string{"cyrene", "furina"} { + selection, err := client.Config().Resolve(persona) + if err != nil { + t.Fatalf("%s: resolve: %v", persona, err) + } + audio, elapsed := synthesizeOrFail(t, client, selection.Character) + sizes[selection.Character] = len(audio.Data) + t.Logf("evidence: persona=%s character=%s source=%s bytes=%d elapsed=%s", + persona, selection.Character, selection.Source, len(audio.Data), elapsed) + } + if sizes["char_84fb88a3"] == 0 || sizes["char_5314489e"] == 0 { + t.Fatalf("两个角色没有都产出音频: %v", sizes) + } + if sizes["char_84fb88a3"] == sizes["char_5314489e"] { + t.Logf("note: 两个角色的音频字节数恰好相同(%d),这可能只是巧合,不改变 voice 已按 persona 切换的事实", sizes["char_84fb88a3"]) + } +} + +// synthesizeOrFail 合成一次并做最小校验,返回音频与耗时。 +func synthesizeOrFail(t *testing.T, client *Client, character string) (Audio, time.Duration) { + t.Helper() start := time.Now() - audio, err := client.Speech(context.Background(), chineseSpeech) + audio, err := client.Speech(context.Background(), character, chineseSpeech) if err != nil { - t.Fatalf("对真服务合成失败: %v", err) + t.Fatalf("对真服务合成失败(character=%s): %v", character, err) } elapsed := time.Since(start) if audio.Format != "wav" { @@ -34,7 +78,5 @@ func TestLiveSpeech(t *testing.T) { if string(audio.Data[:4]) != "RIFF" || string(audio.Data[8:12]) != "WAVE" { t.Fatalf("不是 RIFF/WAVE: %q", string(audio.Data[:12])) } - // 证据:真实字节数与耗时(塔要求的"实际字节")。 - t.Logf("evidence: base=%s character=%s bytes=%d format=%s elapsed=%s", - client.Config().BaseURL, client.Character(), len(audio.Data), audio.Format, elapsed) + return audio, elapsed } diff --git a/internal/runtime/voice/tts/provider.go b/internal/runtime/voice/tts/provider.go index 708fbdf..6ae837e 100644 --- a/internal/runtime/voice/tts/provider.go +++ b/internal/runtime/voice/tts/provider.go @@ -6,6 +6,9 @@ package tts // (`multimodal/voice.New` 组装的各级列表不外露,插进去要先改那条链,超出本切片 // 范围)。提供这个适配是为了让"作为一级"这条路**现在就是通的**:一旦组装方式 // 改成显式列级(`voice.NewChain(...)`),本级可以原样进链,不必重写 HTTP。 +// +// 作为链的一级时**选角同样按 ctx 里的 personaId 现算**(`Config.Resolve`), +// 没有"这一级的固定发音人"这种概念 —— 因此它也不会把角色定死。 import ( "context" @@ -34,12 +37,16 @@ func (p *Provider) TTS() mvoice.TTS { return ttsAdapter{p.client} } // ttsAdapter 把 `Client.Speech` 适配成链要求的 `TTS` 形状(`Audio` 类型不同)。 type ttsAdapter struct{ client *Client } -// TTS 合成音频;client 未接线时返回明确错误而不是 panic。 +// TTS 按 ctx 里的 personaId 选发音人后合成;client 未接线时返回明确错误而不是 panic。 func (a ttsAdapter) TTS(ctx context.Context, text string) (mvoice.Audio, error) { if a.client == nil { return mvoice.Audio{}, mvoice.ErrNotConfigured } - audio, err := a.client.Speech(ctx, text) + selection, err := a.client.Config().Resolve(PersonaIDFromContext(ctx)) + if err != nil { + return mvoice.Audio{}, err + } + audio, err := a.client.Speech(ctx, selection.Character, text) if err != nil { return mvoice.Audio{}, err } diff --git a/internal/runtime/voice/tts/selection.go b/internal/runtime/voice/tts/selection.go new file mode 100644 index 0000000..205d8bc --- /dev/null +++ b/internal/runtime/voice/tts/selection.go @@ -0,0 +1,107 @@ +package tts + +// 本文件是**发音人的选角**:把「本次请求属于哪个角色(personaId)」映射成 +// 「上游要用的 characterId」。它是主人 2026-10-26 那条硬要求的落点: +// +// 「TTS 的人物选择是要绑定后端的角色的,不然会出现以后换了别的角色(人格配置), +// 还是用老角色的 TTS 角色」 +// +// 三条设计约束(违反其一就是那条病害复发): +// +// 1. **单一真源**:映射只来自 `Config.Personas`(面板文件 voice-tts.json)。 +// `configs/personas/*.yaml` **不参与** —— 那是启动期静态配置,面板是运行期 +// 可热调,两处必然漂移。 +// 2. **不许定死**:代码里没有任何兜底 character(`DefaultCharacter` 只是文档示例)。 +// 3. 🔴 **映射缺失时绝不借用别人的声音**:`Default` 只服务「personaId 解析不到」 +// (空串),**不服务**「配了 Personas 但没有这个 persona 的键」——后者一律 +// 拒绝合成,让文本照发。用 default 去顶就是"换了人格还用老角色的声音"。 +// +// 选角是**每次调用现算**的纯函数(读 ctx 里的 personaId + 配置),没有任何跨请求 +// 缓存 —— 因此同一进程内先后服务两个 persona,第二次绝不会沿用第一次的 voice +//(`selection_test.go` 的 `TestResolveIsNotCachedAcrossPersonas` 钉这一条)。 + +import ( + "context" + "errors" + "strings" +) + +// Selection 是本次合成选中的发音人与它的**来源**(来源要进日志:出问题时能一眼 +// 看出"这次是按哪条规则选的")。 +type Selection struct { + // PersonaID 是本次请求所属角色(空 = 解析不到)。 + PersonaID string + // Character 是映射到的上游角色 id(可能带 `char_` 前缀,按原样发出)。 + Character string + // Source 是选角来源:mapped(persona 映射命中)| default(解析不到 persona, + // 用了配置里的 default)。 + Source SelectionSource +} + +// SelectionSource 是选角来源的闭集。 +type SelectionSource string + +const ( + // SourceMapped 表示命中 `Config.Personas` 里该 persona 的映射。 + SourceMapped SelectionSource = "mapped" + // SourceDefault 表示 personaId 解析不到,用了 `Config.Default`。 + SourceDefault SelectionSource = "default" +) + +// 选角失败的原因(可分类 —— 面板/日志据此说清"为什么这次没声音")。 +var ( + // ErrNoCharacter 报告「解析不到 personaId,且没有配 default」⇒ 不合成。 + ErrNoCharacter = errors.New("voice/tts: no character for this request") + // ErrPersonaUnmapped 报告「这个 persona 没有映射」。它**绝不**回落到 default: + // 那会让换了人格的她继续用别的角色的声音(主人点名的病害)。 + ErrPersonaUnmapped = errors.New("voice/tts: persona has no character mapping") +) + +// Resolve 按 personaID 选出本次要用的 character。 +// +// 四条分支(与主人的验收清单逐条对应): +// +// personaID 有映射 ⇒ (Selection{mapped}, nil) +// personaID 为空/解析不到 + 配了 Default ⇒ (Selection{default}, nil) +// personaID 为空/解析不到 + 没配 Default ⇒ ("", ErrNoCharacter) +// personaID 非空 + 没有映射(含映射值为空) ⇒ ("", ErrPersonaUnmapped) +// +// 🔴 第四行**不会**看 Default —— 这正是"不许借用另一个角色"的编码位置。 +func (c Config) Resolve(personaID string) (Selection, error) { + trimmed := strings.TrimSpace(personaID) + if trimmed == "" { + if c.Default == "" { + return Selection{}, ErrNoCharacter + } + return Selection{Character: c.Default, Source: SourceDefault}, nil + } + character := strings.TrimSpace(c.Personas[trimmed]) + if character == "" { + return Selection{PersonaID: trimmed}, ErrPersonaUnmapped + } + return Selection{PersonaID: trimmed, Character: character, Source: SourceMapped}, nil +} + +// personaKey 是 ctx 里携带 personaId 的键(每次请求现传,绝不缓存成全局)。 +type personaKey struct{} + +// WithPersonaID 返回携带本次请求所属角色 id 的 ctx。空串是合法的(= 解析不到), +// 与"没设置"同义 —— 两者都走 default 分支。 +// +// 调用方是**知道会话**的那一层(网关的两条出站口):它先按当次请求解析出 +// `chat_sessions.persona_id`,再交给语音面。语音面自己不去查库,因此这里没有 +// 第二个"会话 → 角色"的解析器(design/22 §4.5:一个解析器)。 +func WithPersonaID(ctx context.Context, personaID string) context.Context { + return context.WithValue(ctx, personaKey{}, strings.TrimSpace(personaID)) +} + +// PersonaIDFromContext 返回 ctx 里携带的角色 id(未设置时为 "")。 +func PersonaIDFromContext(ctx context.Context) string { + if ctx == nil { + return "" + } + if v, ok := ctx.Value(personaKey{}).(string); ok { + return v + } + return "" +} diff --git a/internal/runtime/voice/tts/selection_test.go b/internal/runtime/voice/tts/selection_test.go new file mode 100644 index 0000000..355a788 --- /dev/null +++ b/internal/runtime/voice/tts/selection_test.go @@ -0,0 +1,114 @@ +package tts + +// 本文件锁定**选角**的四条分支(主人 2026-10-26 的验收清单逐条对应)。 +// +// 最要紧的是 `TestResolveUnmappedPersonaRefuses`:映射缺失时**绝不**拿 default +// 或别的角色去顶 —— 那正是主人担心的病害(「换了别的角色,还是用老角色的 TTS +// 角色」)。把这条改成"回落 default"必然打红。 + +import ( + "errors" + "testing" +) + +// selectionCfg 是两份 persona 各映射到**不同** character 的配置。 +func selectionCfg() Config { + return Config{ + Enabled: true, + Personas: map[string]string{ + "cyrene": "char_84fb88a3", + "furina": "char_5314489e", + "nameless": "", + }, + Default: "char_fallback", + } +} + +// TestResolveMapsPersonaToCharacter 分支①:有映射 ⇒ 用映射到的角色(两个 persona +// 各自映射到不同角色,绝不互相串)。 +func TestResolveMapsPersonaToCharacter(t *testing.T) { + cfg := selectionCfg().Normalized(discardLogger()) + + got, err := cfg.Resolve("cyrene") + if err != nil || got.Character != "char_84fb88a3" || got.Source != SourceMapped { + t.Fatalf("cyrene ⇒ %+v err=%v", got, err) + } + other, err := cfg.Resolve("furina") + if err != nil || other.Character != "char_5314489e" || other.Source != SourceMapped { + t.Fatalf("furina ⇒ %+v err=%v", other, err) + } + if got.Character == other.Character { + t.Fatal("两个 persona 拿到了同一个 character(映射被串了)") + } +} + +// TestResolveUnmappedPersonaRefuses 分支④(红线):配了 personas 但**没有这个 +// persona 的键** ⇒ 拒绝,**不看 default**、也不借用任何别的角色。 +func TestResolveUnmappedPersonaRefuses(t *testing.T) { + cfg := selectionCfg().Normalized(discardLogger()) + + got, err := cfg.Resolve("stranger") + if !errors.Is(err, ErrPersonaUnmapped) { + t.Fatalf("未映射 persona err = %v, want ErrPersonaUnmapped", err) + } + if got.Character != "" { + t.Fatalf("未映射 persona 却选出了角色 %q —— 这条就是病害本身", got.Character) + } + // 键在但值为空:同样拒绝(不是"回落 default")。 + if got, err := cfg.Resolve("nameless"); !errors.Is(err, ErrPersonaUnmapped) || got.Character != "" { + t.Fatalf("映射值为空 ⇒ %+v err=%v, want 拒绝", got, err) + } +} + +// TestResolveUnresolvedPersonaUsesDefault 分支②:personaId 解析不到(空串)⇒ 用 +// default(配了才用)。 +func TestResolveUnresolvedPersonaUsesDefault(t *testing.T) { + cfg := selectionCfg().Normalized(discardLogger()) + got, err := cfg.Resolve("") + if err != nil || got.Character != "char_fallback" || got.Source != SourceDefault { + t.Fatalf("空 persona ⇒ %+v err=%v, want default", got, err) + } +} + +// TestResolveUnresolvedPersonaWithoutDefaultRefuses 分支②的另一半:没配 default ⇒ +// 不合成(可分类错误),绝不自己猜一个角色。 +func TestResolveUnresolvedPersonaWithoutDefaultRefuses(t *testing.T) { + cfg := Config{Enabled: true, Personas: map[string]string{"cyrene": "char_84fb88a3"}}.Normalized(discardLogger()) + if got, err := cfg.Resolve(""); !errors.Is(err, ErrNoCharacter) || got.Character != "" { + t.Fatalf("空 persona + 无 default ⇒ %+v err=%v, want ErrNoCharacter", got, err) + } +} + +// TestResolveIsNotCachedAcrossPersonas 是主人担心那件事的**回归锁**:选角是纯函数, +// 同一份配置先后解析两个 persona,第二次绝不沿用第一次的结果。 +func TestResolveIsNotCachedAcrossPersonas(t *testing.T) { + cfg := selectionCfg().Normalized(discardLogger()) + first, err := cfg.Resolve("cyrene") + if err != nil { + t.Fatalf("first: %v", err) + } + second, err := cfg.Resolve("furina") + if err != nil { + t.Fatalf("second: %v", err) + } + if second.Character != "char_5314489e" { + t.Fatalf("第二次沿用了第一次的 voice(%q)—— 正是主人担心的病害", second.Character) + } + if first.Character != "char_84fb88a3" { + t.Fatalf("第一次的结果被第二次改写了: %q", first.Character) + } +} + +// TestPersonaContextRoundTrip 钉住 ctx 传递(语音面自己不查库,只读这一处)。 +func TestPersonaContextRoundTrip(t *testing.T) { + if got := PersonaIDFromContext(WithPersonaID(t.Context(), " cyrene ")); got != "cyrene" { + t.Fatalf("ctx persona = %q, want cyrene", got) + } + if got := PersonaIDFromContext(t.Context()); got != "" { + t.Fatalf("未设置时应为空, got %q", got) + } + // nil ctx 不得 panic(防御性)。 + if got := PersonaIDFromContext(nil); got != "" { + t.Fatalf("nil ctx = %q, want 空", got) + } +} diff --git a/internal/runtime/voice/tts/service.go b/internal/runtime/voice/tts/service.go index 4ab3d8f..681544e 100644 --- a/internal/runtime/voice/tts/service.go +++ b/internal/runtime/voice/tts/service.go @@ -15,6 +15,10 @@ package tts // 🔴 **文本优先**:本层**永不**把 TTS 的失败上抛成"这条回复失败"。失败只做两件事: // 记一条带来源的 WARN + 计数(`Client.Stats`),然后回落既有链;既有链再失败才算 // 整体失败,而那时聊天正文早已发出/落库(TTS 是呈现层的增强,不是回复的前置条件)。 +// +// 🔴 **发音人按当次请求的 persona 现算**(主人 2026-10-26 的硬要求):每次 +// `TTSWithProvider` 都从 ctx 取 personaId、经 `Config.Resolve` 得出 character, +// **没有任何跨请求缓存**。因此"换人格后用老角色的声音"不可能发生。 import ( "context" @@ -79,14 +83,6 @@ func NewService(primary *Client, base Chain, opts ...ServiceOption) *Service { // PrimaryActive 报告 emotionTTS 这一级是否真的在生效(面板据此如实显示"已接")。 func (s *Service) PrimaryActive() bool { return s != nil && s.primary != nil && s.primary.Enabled() } -// Character 返回生效角色(面板展示用)。 -func (s *Service) Character() string { - if s == nil || s.primary == nil { - return "" - } - return s.primary.Character() -} - // Stats 返回 emotionTTS 这一级的计数(尝试/失败);未接线时为 0 值。 func (s *Service) Stats() Stats { if s == nil || s.primary == nil { @@ -125,22 +121,35 @@ func (s *Service) TTS(ctx context.Context, text string) (mvoice.Audio, error) { return audio, err } -// TTSWithProvider 先试 emotionTTS,失败/未启用则回落既有链,并如实报告实际 -// 服务的那一级(`emotiontts` 或既有链的 cloud/local/...)。 +// TTSWithProvider 按**本次请求的角色**选发音人并合成;失败/未启用则回落既有链, +// 并如实报告实际服务的那一级(`emotiontts` 或既有链的 cloud/local/...)。 // -// 三条纪律: -// - **失败绝不上抛成回复失败**:先回落,回落也失败才把后者的错误交给调用方 -// (既有行为逐字不变); -// - **WARN 带来源**:哪一级、什么原因,一条说清(正文绝不进日志,只有长度); -// - **未启用不是故障**:只记 DEBUG,不刷 WARN。 +// 分支(与 `Config.Resolve` 一一对应,验收清单见 selection.go): +// - 选到发音人 ⇒ 用它合成;上游失败 ⇒ 回落既有链 + WARN(口径不变); +// - **选不到发音人**(persona 无映射 / 解析不到且没配 default)⇒ **不合成、 +// 也不回落既有链**,直接回一个可分类错误 + 一条 WARN。回落会让她用**另一个 +// 声音**说话,正是主人点名的病害;文本早已发出,因此这里"没有声音"是安全结局。 func (s *Service) TTSWithProvider(ctx context.Context, text string) (mvoice.Audio, string, error) { - if s.PrimaryActive() { - audio, err := s.primary.Speech(ctx, text) - if err == nil { - return mvoice.Audio{Format: audio.Format, Data: audio.Data}, ProviderName, nil - } - s.degrade(ctx, text, err) + if !s.PrimaryActive() { + return s.fallback(ctx, text) } + selection, err := s.primary.Config().Resolve(PersonaIDFromContext(ctx)) + if err != nil { + // 🔴 绝不借用别的角色的声音:不合成、不回落,如实说清原因。 + s.log.Warn("voice tts skipped: no character for this persona", + "provider", ProviderName, "persona", PersonaIDFromContext(ctx), "text_len", len(text), "err", err) + return mvoice.Audio{}, "", err + } + audio, err := s.primary.Speech(ctx, selection.Character, text) + if err == nil { + return mvoice.Audio{Format: audio.Format, Data: audio.Data}, ProviderName, nil + } + s.degrade(ctx, selection, text, err) + return s.fallback(ctx, text) +} + +// fallback 走既有链;没有底链时回一个明确的错误(不 panic)。 +func (s *Service) fallback(ctx context.Context, text string) (mvoice.Audio, string, error) { if s.base == nil { return mvoice.Audio{}, "", mvoice.ErrProviderFailed } @@ -149,7 +158,7 @@ func (s *Service) TTSWithProvider(ctx context.Context, text string) (mvoice.Audi // degrade 记录一次 emotionTTS 降级:未启用是 DEBUG(正常状态),其余是 WARN // (异常状态,但已被回落吸收)。日志只带文本长度,**绝不带正文**。 -func (s *Service) degrade(ctx context.Context, text string, err error) { +func (s *Service) degrade(ctx context.Context, sel Selection, text string, err error) { if ctx.Err() != nil { // 回合被取消/超时的情形已由 Client 记过(unreachable),这里只补一句 // 「因此这次没声音」,避免同一条失败刷两条 WARN。 @@ -157,5 +166,7 @@ func (s *Service) degrade(ctx context.Context, text string, err error) { return } s.log.Warn("voice tts degraded to fallback chain", - "provider", ProviderName, "text_len", len(text), "err", err) + "provider", ProviderName, "persona", sel.PersonaID, + "character", sel.Character, "source", string(sel.Source), + "text_len", len(text), "err", err) } diff --git a/internal/runtime/voice/tts/service_test.go b/internal/runtime/voice/tts/service_test.go index 3c96530..6086273 100644 --- a/internal/runtime/voice/tts/service_test.go +++ b/internal/runtime/voice/tts/service_test.go @@ -1,17 +1,27 @@ package tts -// 本文件锁定 `Service`(emotionTTS + 既有链的门面)的三条口径: +// 本文件锁定 `Service`(emotionTTS + 既有链的门面)的口径。**断言的落点是 +// 「发往上游的请求体里的 voice」**,不是"调了合成" —— 前者才能证明"换了人格就换了 +// 声音",后者只能证明"发生过一次 HTTP"。 // -// 1. **健康时用 emotionTTS**,并如实报告级别名 `emotiontts`; -// 2. **失败时回落既有链**,且**不把失败上抛**(文本优先:合成挂掉不影响回复); -// 3. **未启用时完全透传**(行为与引入本切片之前逐字相同),STT/克隆永远走既有链。 +// 覆盖: +// 1. 发音人跟着 persona 走(两个 persona ⇒ 两次请求体里的 voice 不同); +// 2. 🔴 同一进程先后用两个 persona,第二次绝不沿用第一次的 voice(回归锁, +// 直接对着主人担心的事); +// 3. persona 无映射且没配 default ⇒ **不合成**,且**上游请求数为 0**; +// 4. persona 解析不到且配了 default ⇒ 用 default; +// 5. 有映射但上游拒绝 ⇒ 沿用既有降级(回落既有链,口径不变); +// 6. 未启用 ⇒ 完全透传既有链;STT/克隆永远走既有链。 import ( "context" + "encoding/json" "errors" "io" "log/slog" "net/http" + "net/http/httptest" + "sync" "testing" mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice" @@ -22,6 +32,77 @@ func discardLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } +// recorder 是一台假 emotionTTS:**记下每次请求体里的 voice**,并按它回一段音频 +// (音频里嵌着 voice,便于端到端断言"帧里的声音是谁的")。 +type recorder struct { + mu sync.Mutex + voices []string + status int + wav bool +} + +// handler 是 recorder 的 HTTP 处理器。 +func (r *recorder) handler(t *testing.T) http.HandlerFunc { + t.Helper() + return func(w http.ResponseWriter, req *http.Request) { + var payload speechPayload + if err := json.NewDecoder(req.Body).Decode(&payload); err != nil { + t.Errorf("请求体不是合法 JSON: %v", err) + } + r.mu.Lock() + r.voices = append(r.voices, payload.Voice) + status, wantWAV := r.status, r.wav + r.mu.Unlock() + if status != 0 && status != http.StatusOK { + w.WriteHeader(status) + _, _ = w.Write([]byte(`{"detail":"upstream says no"}`)) + return + } + if !wantWAV { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":"success"}`)) + return + } + _, _ = w.Write(wavBytes("voice=" + payload.Voice)) + } +} + +// VoiceLog 返回**按顺序**记下的 voice 列表。 +func (r *recorder) VoiceLog() []string { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]string, len(r.voices)) + copy(out, r.voices) + return out +} + +// Calls 返回上游被调用次数。 +func (r *recorder) Calls() int { + r.mu.Lock() + defer r.mu.Unlock() + return len(r.voices) +} + +// newRecordingService 造一个指向 recorder 的 Service。 +func newRecordingService(t *testing.T, cfg Config, base Chain) (*Service, *recorder) { + t.Helper() + rec := &recorder{wav: true} + srv := httptest.NewServer(rec.handler(t)) + t.Cleanup(srv.Close) + cfg.Enabled = true + cfg.BaseURL = srv.URL + client := New(cfg, WithLogger(discardLogger())) + return NewService(client, base, WithServiceLogger(discardLogger())), rec +} + +// personaCfg 是两份 persona ⇒ 两个**不同** character 的配置。 +func personaCfg() Config { + return Config{Personas: map[string]string{ + "cyrene": "char_84fb88a3", + "furina": "char_5314489e", + }} +} + // fakeChain 是一个可编程的底链替身,记录自己被调了几次。 type fakeChain struct { ttsCalls int @@ -56,54 +137,115 @@ func (f *fakeChain) CloneVoice(context.Context, string, string) (string, error) return "voice-id", nil } -// TestServiceUsesPrimaryWhenHealthy 钉住主路:健康时走 emotionTTS 且报告级别名。 -func TestServiceUsesPrimaryWhenHealthy(t *testing.T) { - client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write(wavBytes("her voice")) - }) +// TestServiceVoiceFollowsPersona 分支①:两次请求、两个 persona ⇒ 请求体里的 +// voice 分别是两个不同的 character。 +func TestServiceVoiceFollowsPersona(t *testing.T) { base := &fakeChain{audio: mvoice.Audio{Format: "mp3", Data: []byte("fallback")}} - svc := NewService(client, base, WithServiceLogger(discardLogger())) + svc, rec := newRecordingService(t, personaCfg(), base) - audio, rung, err := svc.TTSWithProvider(context.Background(), chineseSpeech) - if err != nil { - t.Fatalf("TTSWithProvider: %v", err) + for _, persona := range []string{"cyrene", "furina"} { + ctx := WithPersonaID(context.Background(), persona) + if _, rung, err := svc.TTSWithProvider(ctx, chineseSpeech); err != nil || rung != ProviderName { + t.Fatalf("%s: rung=%q err=%v", persona, rung, err) + } } - if rung != ProviderName { - t.Errorf("rung = %q, want %q", rung, ProviderName) - } - if audio.Format != "wav" || string(audio.Data) == "fallback" { - t.Errorf("audio = %+v, want the emotionTTS bytes", audio) + want := []string{"char_84fb88a3", "char_5314489e"} + got := rec.VoiceLog() + if len(got) != 2 || got[0] != want[0] || got[1] != want[1] { + t.Fatalf("请求体里的 voice = %v, want %v", got, want) } if base.ttsCalls != 0 { t.Errorf("主路健康却回落了底链 %d 次", base.ttsCalls) } } -// TestServiceFallsBackWhenPrimaryFails 钉住降级:主路失败 ⇒ 底链出声音, -// **错误不上抛**(文本优先)。 -func TestServiceFallsBackWhenPrimaryFails(t *testing.T) { - client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - _, _ = w.Write([]byte(`{"detail":"indexTTS2 down"}`)) - }) - base := &fakeChain{audio: mvoice.Audio{Format: "mp3", Data: []byte("fallback")}} - svc := NewService(client, base, WithServiceLogger(discardLogger())) +// TestServiceSequentialPersonasDoNotReuseVoice 是主人担心那件事的**回归锁**: +// 同一进程先服务 cyrene 再服务 furina,第二次请求体里的 voice 必须是 furina 的, +// **绝不**沿用第一次。 +func TestServiceSequentialPersonasDoNotReuseVoice(t *testing.T) { + svc, rec := newRecordingService(t, personaCfg(), &fakeChain{}) + ctx := context.Background() - audio, rung, err := svc.TTSWithProvider(context.Background(), chineseSpeech) + if _, _, err := svc.TTSWithProvider(WithPersonaID(ctx, "cyrene"), "第一句"); err != nil { + t.Fatalf("first: %v", err) + } + if _, _, err := svc.TTSWithProvider(WithPersonaID(ctx, "furina"), "第二句"); err != nil { + t.Fatalf("second: %v", err) + } + got := rec.VoiceLog() + if len(got) != 2 { + t.Fatalf("上游被调用 %d 次, want 2", len(got)) + } + if got[1] != "char_5314489e" { + t.Fatalf("第二次请求体里的 voice = %q —— 沿用了第一次的角色(病害复发)", got[1]) + } +} + +// TestServiceUnmappedPersonaSkipsSynthesis 分支④(红线):persona 无映射、且**没配** +// default ⇒ 不合成:上游请求数为 0,底链也**不许**顶替(那会用另一个声音)。 +func TestServiceUnmappedPersonaSkipsSynthesis(t *testing.T) { + base := &fakeChain{audio: mvoice.Audio{Format: "wav", Data: []byte("fallback")}} + svc, rec := newRecordingService(t, personaCfg(), base) + + _, _, err := svc.TTSWithProvider(WithPersonaID(context.Background(), "stranger"), chineseSpeech) + if !errors.Is(err, ErrPersonaUnmapped) { + t.Fatalf("err = %v, want ErrPersonaUnmapped", err) + } + if rec.Calls() != 0 { + t.Fatalf("上游请求数 = %d, want 0(这条 persona 没有发音人,不该发任何合成请求)", rec.Calls()) + } + if base.ttsCalls != 0 { + t.Fatalf("回落到了既有链 %d 次 —— 那会用另一个角色的声音说话", base.ttsCalls) + } +} + +// TestServiceUnresolvedPersonaUsesDefault 分支②:解析不到 personaId(ctx 没设)且 +// 配了 default ⇒ 请求体里的 voice 是 default。与上一条互为对照。 +func TestServiceUnresolvedPersonaUsesDefault(t *testing.T) { + cfg := personaCfg() + cfg.Default = "char_default_voice" + svc, rec := newRecordingService(t, cfg, &fakeChain{}) + + if _, _, err := svc.TTSWithProvider(context.Background(), chineseSpeech); err != nil { + t.Fatalf("TTSWithProvider: %v", err) + } + got := rec.VoiceLog() + if len(got) != 1 || got[0] != "char_default_voice" { + t.Fatalf("请求体里的 voice = %v, want [char_default_voice]", got) + } +} + +// TestServiceUnresolvedPersonaWithoutDefaultSkips 分支②的另一半:没配 default ⇒ +// 不合成(请求数 0),与"用 default"严格区分。 +func TestServiceUnresolvedPersonaWithoutDefaultSkips(t *testing.T) { + svc, rec := newRecordingService(t, personaCfg(), &fakeChain{}) + _, _, err := svc.TTSWithProvider(context.Background(), chineseSpeech) + if !errors.Is(err, ErrNoCharacter) { + t.Fatalf("err = %v, want ErrNoCharacter", err) + } + if rec.Calls() != 0 { + t.Fatalf("上游请求数 = %d, want 0", rec.Calls()) + } +} + +// TestServiceFallsBackWhenUpstreamRejects 分支③:有映射但上游拒绝(500)⇒ 沿用既有 +// 降级(回落既有链 + WARN),且**先按正确角色试过**(请求体里的 voice 是映射到的那个)。 +func TestServiceFallsBackWhenUpstreamRejects(t *testing.T) { + base := &fakeChain{audio: mvoice.Audio{Format: "mp3", Data: []byte("fallback")}} + svc, rec := newRecordingService(t, personaCfg(), base) + rec.mu.Lock() + rec.status = http.StatusInternalServerError + rec.mu.Unlock() + + audio, rung, err := svc.TTSWithProvider(WithPersonaID(context.Background(), "furina"), chineseSpeech) if err != nil { t.Fatalf("主路失败不该让合成整体失败(文本优先): %v", err) } - if rung != "silent" { - t.Errorf("rung = %q, want 底链报告的那一级", rung) + if rung != "silent" || string(audio.Data) != "fallback" || base.ttsCalls != 1 { + t.Fatalf("没有按既有口径回落: rung=%q audio=%q calls=%d", rung, audio.Data, base.ttsCalls) } - if string(audio.Data) != "fallback" { - t.Errorf("audio = %+v, want 底链产物", audio) - } - if base.ttsCalls != 1 { - t.Errorf("底链被调用 %d 次, want 1", base.ttsCalls) - } - if got := svc.Stats(); got.Failures != 1 { - t.Errorf("降级没有计数: %+v", got) + if got := rec.VoiceLog(); len(got) != 1 || got[0] != "char_5314489e" { + t.Fatalf("失败那次用的 voice = %v, want [char_5314489e](应先按正确角色试)", got) } } @@ -159,18 +301,32 @@ func TestServiceDelegatesSTTAndClone(t *testing.T) { } } -// TestProviderAdapter 钉住"作为链的一级"这条路现在就是通的(STT 方向为 nil)。 -func TestProviderAdapter(t *testing.T) { - client := New(Config{Enabled: true, BaseURL: "http://127.0.0.1:1"}, WithLogger(discardLogger())) - p := NewProvider(client) +// TestProviderAdapterSelectsByPersona 钉住"作为链的一级"这条路:选角同样按 ctx 的 +// personaId 现算(没有"这一级的固定发音人")。 +func TestProviderAdapterSelectsByPersona(t *testing.T) { + rec := &recorder{wav: true} + srv := httptest.NewServer(rec.handler(t)) + t.Cleanup(srv.Close) + cfg := personaCfg() + cfg.Enabled = true + cfg.BaseURL = srv.URL + p := NewProvider(New(cfg, WithLogger(discardLogger()))) + if p.Name() != ProviderName { t.Errorf("Name = %q, want %q", p.Name(), ProviderName) } if p.STT() != nil { t.Error("emotionTTS 不提供转写,STT 应为 nil") } - if _, err := p.TTS().TTS(context.Background(), " "); !errors.Is(err, ErrEmptyText) { - t.Errorf("err = %v, want ErrEmptyText(穿透到 Client 的守卫)", err) + if _, err := p.TTS().TTS(WithPersonaID(context.Background(), "furina"), "你好"); err != nil { + t.Fatalf("TTS: %v", err) + } + if got := rec.VoiceLog(); len(got) != 1 || got[0] != "char_5314489e" { + t.Fatalf("请求体里的 voice = %v, want [char_5314489e]", got) + } + // 未映射的 persona ⇒ 明确失败,绝不用默认角色凑一个。 + if _, err := p.TTS().TTS(WithPersonaID(context.Background(), "stranger"), "你好"); !errors.Is(err, ErrPersonaUnmapped) { + t.Fatalf("err = %v, want ErrPersonaUnmapped", err) } if _, err := NewProvider(nil).TTS().TTS(context.Background(), "hi"); !errors.Is(err, mvoice.ErrNotConfigured) { t.Errorf("未接线 err = %v, want ErrNotConfigured", err) diff --git a/internal/services/gateway/audio.go b/internal/services/gateway/audio.go index 58e38f2..64a5a6d 100644 --- a/internal/services/gateway/audio.go +++ b/internal/services/gateway/audio.go @@ -8,6 +8,7 @@ import ( apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice" + voicetts "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/tts" ) // maxAudioRequestBytes 限定 /api/v1/audio/* 请求可携带的 JSON 体大小, @@ -27,11 +28,17 @@ type transcribeRequest struct { // VOICE_TTS_VOICE_ID / VOICE_TTS_FORMAT 配置。Instruction 是可选的 // 每请求情绪/语气覆盖,用于克隆音色(声音复刻);系统音色 // (longxiaochun_v3)忽略它,绝不接收自由形式的指令。 +// +// SessionID 是**可选**的会话标识(切片 M35,纯增量):发音人按 +// 「该会话绑定的人格」解析(`chat_sessions.persona_id`)。省略它时本口没有会话 +// 概念 ⇒ 走 emotionTTS 的 default 分支(没配 default 就不合成)。客户端**绝不** +// 通过它选音色:音色由服务端的 persona 映射决定,请求只声明"这是哪个会话"。 type speechRequest struct { Text string `json:"text"` Voice string `json:"voice,omitempty"` Format string `json:"format,omitempty"` Instruction string `json:"instruction,omitempty"` + SessionID string `json:"sessionId,omitempty"` } // handleTranscribe 对上传的音频执行 STT 并返回识别出的 @@ -100,6 +107,10 @@ func (s *Server) handleSpeech(w http.ResponseWriter, r *http.Request) { if req.Instruction != "" { ctx = voice.WithTTSInstruction(ctx, req.Instruction) } + // 发音人按**本次请求的会话**所绑定的人格解析(切片 M35)。空 sessionId 或 + // 会话无绑定 ⇒ personaID 为空 ⇒ 语音面走 default 分支(没有 default 就不合成)。 + // 解析只做一次、只服务本次请求,绝不在注入时算一次并缓存成全局。 + ctx = voicetts.WithPersonaID(ctx, s.envResolver.sessionPersona(ctx, req.SessionID)) audi, rung, err := ttsWithProvider(ctx, s.voice, req.Text) if err != nil { s.auditClaims(r, "audio.speech", "error") diff --git a/internal/services/gateway/voice.go b/internal/services/gateway/voice.go index 68e5732..6049e81 100644 --- a/internal/services/gateway/voice.go +++ b/internal/services/gateway/voice.go @@ -12,6 +12,7 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice" + voicetts "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/tts" ) // VoiceService 为 voice_input WS 路由运行语音转文本与 @@ -177,7 +178,7 @@ func (h *hub) runVoiceTurn(c *wsConn, req voiceInputRequest) { msgs := []model.Message{model.TextMessage(model.RoleUser, transcript)} // 两条「怎么回到这条连接」的通路都由本连接给出:收尾回调(音频回复,C1)与 // 定向拒绝回调(队列满,G5)。编排器只把它们当成本回合的传输线索。 - ctx = withVoiceReplySink(ctx, h.voiceReplySink(c, req.SessionID, req.DeviceID, transcript)) + ctx = withVoiceReplySink(ctx, h.voiceReplySink(c, req.SessionID, req.DeviceID, env.PersonaID, transcript)) ctx = withPendingFullNotifier(ctx, c.notifyPendingFull) if h.orchestrator != nil { h.orchestrator.Submit(ctx, req.SessionID, msgs, env) @@ -196,16 +197,20 @@ func (h *hub) runVoiceTurnDirect(ctx context.Context, c *wsConn, sessionID, devi h.voiceError(c, "voice chat failed") return } - h.synthesizeVoiceReply(ctx, c, sessionID, deviceID, transcript, voiceReplyText(res)) + h.synthesizeVoiceReply(ctx, c, sessionID, deviceID, env.PersonaID, transcript, voiceReplyText(res)) } // voiceReplySink 返回本次语音请求的收尾回调:回合结算后取助手正文 → TTS → // **定向**回发起它的那根连接。它是 C1 的核心接线点 —— 回合异步完成,只有结算处 // 知道助手正文。 // +// personaID 是**本回合**解析出的角色(`env.PersonaID`,会话无绑定时为空)。它随 +// 回调一起传下去,使合成按这一回合的角色选发音人 —— 而不是进程启动时定死一个。 +// 它不是身份输入(身份仍由 actor 元数据决定),只决定用哪个上游音色。 +// // 连接已断(客户端走了)时记一条带来源的 WARN,绝不静默丢:那正是「静默成功但 // 没声音」那类缺陷的温床(协同 C2 的教训)。 -func (h *hub) voiceReplySink(c *wsConn, sessionID, deviceID, transcript string) VoiceReplyFunc { +func (h *hub) voiceReplySink(c *wsConn, sessionID, deviceID, personaID, transcript string) VoiceReplyFunc { return func(res *loop.Result) { if c.closed() { h.voiceLog().Warn("voice reply dropped", "reason", "connection closed", "session", sessionID, "device_id", deviceID) @@ -213,26 +218,31 @@ func (h *hub) voiceReplySink(c *wsConn, sessionID, deviceID, transcript string) } // 回合的 ctx 已随回合消亡,因此合成用全新的后台 context:音频是「回复已 // 落库」之后的增强,不该被一次被取消的回合连带取消。 - h.synthesizeVoiceReply(context.Background(), c, sessionID, deviceID, transcript, voiceReplyText(res)) + h.synthesizeVoiceReply(context.Background(), c, sessionID, deviceID, personaID, transcript, voiceReplyText(res)) } } // synthesizeVoiceReply 把助手正文合成音频并定向回发起这条语音的连接 -// (协同 C1/C3)。三条纪律: +// (协同 C1/C3)。四条纪律: // - TTS 失败或降级为空音频时**照旧回帧**并带既有 provider 降级信号,让客户端 // 能提示「没生成声音」(绝不是只回一条错误帧就完事); // - 没有助手正文(被取消 / 空回复)时不打一次无谓的 TTS,直接回降级帧 —— // 客户端总能看到这条语音回合的终点,而不是永远等一个不会来的 voice_output; +// - **发音人按本回合的 personaID 现解析**(`voicetts.WithPersonaID`):换人格后 +// 绝不会继续用老角色的声音(主人 2026-10-26 的硬要求); // - 日志只带会话与设备 id,**绝不记正文/音频**。 -func (h *hub) synthesizeVoiceReply(ctx context.Context, c *wsConn, sessionID, deviceID, transcript, reply string) { +func (h *hub) synthesizeVoiceReply(ctx context.Context, c *wsConn, sessionID, deviceID, personaID, transcript, reply string) { if strings.TrimSpace(reply) == "" { h.voiceLog().Warn("voice reply empty", "reason", "turn produced no text", "session", sessionID, "device_id", deviceID) emitVoiceOutput(c, sessionID, deviceID, transcript, "", voice.Audio{}, providerSignal("", true)) return } + // 把**本回合**的角色交给语音面选发音人;空 personaID = 会话无绑定 ⇒ 语音面 + // 走它的 default 分支(没配 default 就不合成,文本已发出)。 + ctx = voicetts.WithPersonaID(ctx, personaID) audi, rung, err := ttsWithProvider(ctx, h.voice, reply) if err != nil { - h.voiceLog().Warn("voice tts", "err", err, "session", sessionID, "device_id", deviceID) + h.voiceLog().Warn("voice tts", "err", err, "session", sessionID, "device_id", deviceID, "persona", personaID) emitVoiceOutput(c, sessionID, deviceID, transcript, reply, voice.Audio{}, providerSignal("", true)) return } diff --git a/internal/services/gateway/voice_emotiontts_test.go b/internal/services/gateway/voice_emotiontts_test.go index 9ab4051..ccbd2e5 100644 --- a/internal/services/gateway/voice_emotiontts_test.go +++ b/internal/services/gateway/voice_emotiontts_test.go @@ -1,145 +1,260 @@ -package gateway - -// 本文件是切片 M35 的**两条既有出站口实测**:把真的 emotionTTS provider -// (指向一个 httptest 上游)注入网关,然后 -// -// 1. `POST /api/v1/audio/speech` 回的**是音频字节**(而不是 JSON 信封/空体); -// 2. WS `voice_output` 帧里带的**是那段音频的 base64**。 -// -// 它钉的是"接上 provider ≠ 用户能听到"这件事:两条口各自的输出都被逐字节断言过。 - -import ( - "context" - "encoding/base64" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" - "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" - mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice" - voicetts "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/tts" -) - -// fakeWAV 是一段魔数合法的假 WAV(本文件的断言只关心"字节原样跑到客户端")。 -func fakeWAV() []byte { - b := []byte("RIFF\x00\x00\x00\x00WAVEfmt ") - b = append(b, make([]byte, 16)...) - b = append(b, []byte("data\x00\x00\x00\x00")...) - return append(b, []byte("her-emotion-voice")...) -} - -// emotionTTSServer 起一台假的 emotionTTS:校验请求是 UTF-8 JSON,回一段 WAV。 -func emotionTTSServer(t *testing.T) *httptest.Server { - t.Helper() - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/v1/audio/speech" { - t.Errorf("path = %q, want /v1/audio/speech", r.URL.Path) - } - var payload struct { - Model string `json:"model"` - Input string `json:"input"` - Voice string `json:"voice"` - } - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - t.Errorf("请求体不是合法 UTF-8 JSON: %v", err) - } - if payload.Voice != voicetts.DefaultCharacter || payload.Model != "emotionTTS" { - t.Errorf("payload = %+v, want emotionTTS / 默认角色", payload) - } - w.Header().Set("Content-Type", "audio/wav") - _, _ = w.Write(fakeWAV()) - })) - t.Cleanup(srv.Close) - return srv -} - -// emotionTTSService 用假上游构造一个**真的** tts.Service(不是 stub)。 -func emotionTTSService(t *testing.T, base voicetts.Chain) *voicetts.Service { - t.Helper() - srv := emotionTTSServer(t) - client := voicetts.New(voicetts.Config{Enabled: true, BaseURL: srv.URL}) - return voicetts.NewService(client, base) -} - -// TestSpeechEndpointServesEmotionTTSAudio 是 REST 口(`POST /api/v1/audio/speech`) -// 的实测证据:状态 200、Content-Type 音频、体就是上游那串字节。 -func TestSpeechEndpointServesEmotionTTSAudio(t *testing.T) { - env := newEnv(t, nil, false) - env.srv.SetVoice(emotionTTSService(t, nil)) - - token := env.token(t, auth.RoleOwner) - resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/audio/speech", map[string]any{ - "text": "你好,我是苏梦。", - }, token) - if resp.StatusCode != http.StatusOK { - t.Fatalf("status = %d, body = %s", resp.StatusCode, body) - } - if ct := resp.Header.Get("Content-Type"); ct != "audio/wav" { - t.Errorf("Content-Type = %q, want audio/wav", ct) - } - if len(body) == 0 { - t.Fatal("响应体为空:用户听不到任何声音") - } - if string(body) != string(fakeWAV()) { - t.Fatalf("body 不是上游音频字节: %d bytes", len(body)) - } -} - -// TestVoiceOutputFrameCarriesEmotionTTSAudio 是 WS 口(`voice_output` 帧)的实测 -// 证据:帧里 audio 字段解码后就是上游那串字节,且 text/reply 都在。 -func TestVoiceOutputFrameCarriesEmotionTTSAudio(t *testing.T) { - hb := newTestHub(&stubRunner{res: &loop.Result{Text: "你好,我是苏梦。", Steps: 1, StopReason: "stop"}}, nil) - hb.voice = emotionTTSService(t, &silentBase{}) - c := newOwnerConn(hb, 32) - hb.register(c) - - c.route(wsInbound{Type: "voice_input", Data: json.RawMessage(`{"text":"你好","format":"wav"}`)}) - ev := readFakeFrame(t, c) - if ev.Type != "voice_output" { - t.Fatalf("frame = %q, want voice_output (%+v)", ev.Type, ev) - } - data, ok := ev.Data.(map[string]any) - if !ok { - t.Fatalf("frame data = %T", ev.Data) - } - encoded, _ := data["audio"].(string) - if encoded == "" { - t.Fatal("帧里没有 audio:用户听不到任何声音") - } - raw, err := base64.StdEncoding.DecodeString(encoded) - if err != nil { - t.Fatalf("audio 不是合法 base64: %v", err) - } - if string(raw) != string(fakeWAV()) { - t.Fatalf("帧里的音频不是上游字节: %d bytes", len(raw)) - } - if data["reply"] != "你好,我是苏梦。" { - t.Errorf("reply = %v, want 助手正文", data["reply"]) - } -} - -// silentBase 是最小的底链替身:只在测"主路健康"时用作类型占位, -// 一旦被调用就说明主路降级了(测试会因此失败)。 -type silentBase struct{} - -func (s *silentBase) STT(_ context.Context, _ mvoice.Audio) (string, error) { - return "", mvoice.ErrProviderFailed -} - -func (s *silentBase) STTWithProvider(_ context.Context, _ mvoice.Audio) (string, string, error) { - return "", "", mvoice.ErrProviderFailed -} - -func (s *silentBase) TTS(_ context.Context, _ string) (mvoice.Audio, error) { - return mvoice.Audio{}, mvoice.ErrProviderFailed -} - -func (s *silentBase) TTSWithProvider(_ context.Context, _ string) (mvoice.Audio, string, error) { - return mvoice.Audio{}, "", mvoice.ErrProviderFailed -} - -func (s *silentBase) CloneVoice(_ context.Context, _, _ string) (string, error) { - return "", mvoice.ErrProviderFailed -} +package gateway + +// 本文件是切片 M35 的**两条既有出站口实测**,并且钉住主人 2026-10-26 的硬要求: +// **发音人跟着会话的人格走**(绝不定死一个)。 +// +// 1. `POST /api/v1/audio/speech`:回的是音频字节(不是 JSON 信封/空体),且 +// 请求体里的 `sessionId` 解析到的人格决定上游 `voice`; +// 2. WS `voice_output`:帧里带的是那段音频的 base64,且发音人取**本回合**的 +// `env.PersonaID`(不是进程启动时算一次)。 +// +// 断言落在「上游请求体里的 voice」与「帧里音频的内容」上 —— 它们才能证明 +// "换了人格就换了声音"。 + +import ( + "context" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" + mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice" + voicetts "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/tts" +) + +// personaCharacters 是两个角色各自的上游 character id(与生产配置同形)。 +var personaCharacters = map[string]string{ + "cyrene": "char_84fb88a3", + "furina": "char_5314489e", +} + +// fakeWAV 造一段魔数合法、且**内容里带着 voice 名**的假 WAV —— 这样端到端断言 +// 帧/响应里的字节就能直接看出"这段声音是谁的"。 +func fakeWAV(voice string) []byte { + b := []byte("RIFF\x00\x00\x00\x00WAVEfmt ") + b = append(b, make([]byte, 16)...) + b = append(b, []byte("data\x00\x00\x00\x00")...) + return append(b, []byte("voice="+voice)...) +} + +// vocalRecorder 是一台假 emotionTTS:记下每次请求体里的 voice。 +type vocalRecorder struct { + mu sync.Mutex + voices []string +} + +// handler 解码请求体、记录 voice、回一段内容里带 voice 的 WAV。 +func (r *vocalRecorder) handler(t *testing.T) http.HandlerFunc { + t.Helper() + return func(w http.ResponseWriter, req *http.Request) { + if req.URL.Path != "/v1/audio/speech" { + t.Errorf("path = %q, want /v1/audio/speech", req.URL.Path) + } + var payload struct { + Model string `json:"model"` + Input string `json:"input"` + Voice string `json:"voice"` + } + if err := json.NewDecoder(req.Body).Decode(&payload); err != nil { + t.Errorf("请求体不是合法 UTF-8 JSON: %v", err) + } + if payload.Model != "emotionTTS" { + t.Errorf("model = %q, want emotionTTS", payload.Model) + } + r.mu.Lock() + r.voices = append(r.voices, payload.Voice) + r.mu.Unlock() + w.Header().Set("Content-Type", "audio/wav") + _, _ = w.Write(fakeWAV(payload.Voice)) + } +} + +// Voices 返回**按顺序**记下的 voice。 +func (r *vocalRecorder) Voices() []string { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]string, len(r.voices)) + copy(out, r.voices) + return out +} + +// emotionTTSService 用假上游构造一个**真的** tts.Service(不是 stub)。 +// +// cfg 由调用方覆盖(personas / default),本函数只补齐 Enabled 与 BaseURL。 +func emotionTTSService(t *testing.T, rec *vocalRecorder, cfg voicetts.Config, base voicetts.Chain) *voicetts.Service { + t.Helper() + srv := httptest.NewServer(rec.handler(t)) + t.Cleanup(srv.Close) + cfg.Enabled = true + cfg.BaseURL = srv.URL + return voicetts.NewService(voicetts.New(cfg), base) +} + +// personaHistory 是一个**只报一个 persona** 的 history 替身(满足 +// `sessionPersonaReader`),用来驱动两条口的人格解析。它复用测试包里的 +// fakeHistory 以满足 HistoryStore 的其余方法。 +type personaHistory struct { + *fakeHistory + persona string +} + +// SessionPersona 报该 persona(未知会话与读错误对上层都退化成 "",这里只测成功路径)。 +func (h personaHistory) SessionPersona(context.Context, string) (string, error) { + return h.persona, nil +} + +// personaResolver 造一个把 sessionID 解析成给定 persona 的解析器。 +func personaResolver(persona string) turnEnvResolver { + return turnEnvResolver{history: personaHistory{fakeHistory: &fakeHistory{}, persona: persona}, log: sourcedLog(nil)} +} + +// TestSpeechEndpointVoiceFollowsSessionPersona 是 REST 口的实测:同一个进程、同一台 +// 服务,**先**用 cyrene 会话、**后**用 furina 会话 ⇒ 两次上游请求体里的 voice 分别是 +// 两个不同的角色(第二次绝不沿用第一次)。 +func TestSpeechEndpointVoiceFollowsSessionPersona(t *testing.T) { + env := newEnv(t, nil, false) + rec := &vocalRecorder{} + base := &silentBase{} + env.srv.SetVoice(emotionTTSService(t, rec, voicetts.Config{Personas: personaCharacters}, base)) + token := env.token(t, auth.RoleOwner) + + for _, persona := range []string{"cyrene", "furina"} { + env.srv.envResolver = personaResolver(persona) + resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/audio/speech", map[string]any{ + "text": "你好,我是苏梦。", "sessionId": "web:" + persona, + }, token) + if resp.StatusCode != http.StatusOK { + t.Fatalf("%s: status = %d, body = %s", persona, resp.StatusCode, body) + } + if ct := resp.Header.Get("Content-Type"); ct != "audio/wav" { + t.Errorf("%s: Content-Type = %q, want audio/wav", persona, ct) + } + if string(body) != string(fakeWAV(personaCharacters[persona])) { + t.Fatalf("%s: 响应体不是该角色的音频: %d bytes", persona, len(body)) + } + } + want := []string{personaCharacters["cyrene"], personaCharacters["furina"]} + got := rec.Voices() + if len(got) != 2 || got[0] != want[0] || got[1] != want[1] { + t.Fatalf("请求体里的 voice = %v, want %v", got, want) + } +} + +// TestSpeechEndpointUnmappedPersonaRefuses 是 REST 口的红线:会话绑的 persona 没有 +// 映射 ⇒ **不合成**(上游 0 次请求),接口如实报错,绝不借用别的角色。 +func TestSpeechEndpointUnmappedPersonaRefuses(t *testing.T) { + env := newEnv(t, nil, false) + rec := &vocalRecorder{} + env.srv.SetVoice(emotionTTSService(t, rec, voicetts.Config{ + Personas: personaCharacters, + Default: personaCharacters["cyrene"], // 配了 default 也**不许**顶未映射的 persona + }, &silentBase{})) + env.srv.envResolver = personaResolver("stranger") + + token := env.token(t, auth.RoleOwner) + resp, _ := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/audio/speech", map[string]any{ + "text": "你好", "sessionId": "web:sub-9", + }, token) + if resp.StatusCode == http.StatusOK { + t.Fatal("未映射 persona 却回了 200 —— 会让她用别人的声音说话") + } + if got := rec.Voices(); len(got) != 0 { + t.Fatalf("上游请求数 = %d, want 0(voice=%v)", len(got), got) + } +} + +// TestSpeechEndpointNoSessionUsesDefault 钉住"这条口没有会话时"的诚实处置: +// 省略 sessionId ⇒ persona 解析不到 ⇒ 用配置的 default(没配则同样不合成)。 +func TestSpeechEndpointNoSessionUsesDefault(t *testing.T) { + env := newEnv(t, nil, false) + rec := &vocalRecorder{} + defaultVoice := "char_default_voice" + env.srv.SetVoice(emotionTTSService(t, rec, voicetts.Config{ + Personas: personaCharacters, + Default: defaultVoice, + }, &silentBase{})) + token := env.token(t, auth.RoleOwner) + + resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/audio/speech", map[string]any{ + "text": "你好", + }, token) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, body = %s", resp.StatusCode, body) + } + if got := rec.Voices(); len(got) != 1 || got[0] != defaultVoice { + t.Fatalf("请求体里的 voice = %v, want [%s]", got, defaultVoice) + } +} + +// TestVoiceOutputFrameVoiceFollowsTurnPersona 是 WS 口的实测:发音人取**本回合**的 +// env.PersonaID。两次回合(两个 persona)⇒ 两次帧里的音频内容分别属于两个角色。 +func TestVoiceOutputFrameVoiceFollowsTurnPersona(t *testing.T) { + for _, persona := range []string{"cyrene", "furina"} { + t.Run(persona, func(t *testing.T) { + hb := newTestHub(&stubRunner{res: &loop.Result{Text: "你好,我是苏梦。", Steps: 1, StopReason: "stop"}}, nil) + hb.envResolver = personaResolver(persona) + rec := &vocalRecorder{} + hb.voice = emotionTTSService(t, rec, voicetts.Config{Personas: personaCharacters}, &silentBase{}) + c := newOwnerConn(hb, 32) + hb.register(c) + + c.route(wsInbound{Type: "voice_input", Data: json.RawMessage(`{"text":"你好","format":"wav"}`)}) + ev := readFakeFrame(t, c) + if ev.Type != "voice_output" { + t.Fatalf("frame = %q, want voice_output (%+v)", ev.Type, ev) + } + data, ok := ev.Data.(map[string]any) + if !ok { + t.Fatalf("frame data = %T", ev.Data) + } + raw, err := base64.StdEncoding.DecodeString(asString(data["audio"])) + if err != nil || len(raw) == 0 { + t.Fatalf("帧里没有可解码的音频(err=%v)", err) + } + want := fakeWAV(personaCharacters[persona]) + if string(raw) != string(want) { + t.Fatalf("帧里的声音不是 %s 的: %q", persona, string(raw)) + } + if data["reply"] != "你好,我是苏梦。" { + t.Errorf("reply = %v, want 助手正文", data["reply"]) + } + if got := rec.Voices(); len(got) != 1 || got[0] != personaCharacters[persona] { + t.Fatalf("请求体里的 voice = %v, want [%s]", got, personaCharacters[persona]) + } + }) + } +} + +// asString 安全地取一个 string 值(帧载荷是 map[string]any)。 +func asString(v any) string { + s, _ := v.(string) + return s +} + +// silentBase 是最小的底链替身:只在测"主路健康"时用作类型占位, +// 一旦被调用就说明主路降级了(测试会因此失败)。 +type silentBase struct{} + +func (s *silentBase) STT(_ context.Context, _ mvoice.Audio) (string, error) { + return "", mvoice.ErrProviderFailed +} + +func (s *silentBase) STTWithProvider(_ context.Context, _ mvoice.Audio) (string, string, error) { + return "", "", mvoice.ErrProviderFailed +} + +func (s *silentBase) TTS(_ context.Context, _ string) (mvoice.Audio, error) { + return mvoice.Audio{}, mvoice.ErrProviderFailed +} + +func (s *silentBase) TTSWithProvider(_ context.Context, _ string) (mvoice.Audio, string, error) { + return mvoice.Audio{}, "", mvoice.ErrProviderFailed +} + +func (s *silentBase) CloneVoice(_ context.Context, _, _ string) (string, error) { + return "", mvoice.ErrProviderFailed +} From 8820b0b316cce05246cfa9a8ad973c295a7175f5 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 26 Oct 2026 13:06:39 +0800 Subject: [PATCH 3/6] =?UTF-8?q?fix(voice):=20=E4=BF=AE=20r1=20=E5=A4=8D?= =?UTF-8?q?=E5=AE=A1=E5=9B=9B=E6=9D=A1=EF=BC=88=E6=AD=A3=E6=96=87=E8=BF=9B?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E7=9A=84=E9=9A=90=E7=A7=81=E6=B3=84=E6=BC=8F?= =?UTF-8?q?=20/=20format=20=E5=81=87=E5=BC=80=E5=85=B3=20/=20Stats=20?= =?UTF-8?q?=E6=97=A0=E6=B6=88=E8=B4=B9=E8=80=85=20/=20redactURL=20?= =?UTF-8?q?=E5=85=9C=E5=BA=95=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一版把 r1 的三条 finding 静默漏了(我在交付前没读 inbox),本提交逐条补齐: 1. 🔴 隐私:upstreamStatus 不再读也不记响应体 FastAPI 的 422 会把请求体里的 input 整段回显,原来「错误体前 512 字节进 WARN」 等于把她的原话写进日志。现在只记 status / content_type / response_bytes (字节数优先取 Content-Length,缺失时有界排空取上界,读到即丢)。 新用例 TestSpeechRejectLogOmitsRequestText 用**回显正文**的假上游抓它; 变异(把 body 塞回 WARN)实测打红。 2. format 键彻底删除(假开关) 上游只拿 response_format 改自报 media_type,产物恒 .wav 无转码,我方只按字节 嗅探裁决 ⇒ wav/mp3/ogg 交付字节与响应头完全相同。删 tts.Config.Format 与 VoiceTTSTuning.Format,encodeSpeech 硬编码 requestFormat="wav"(不可配置)。 用例钉住请求体恒为 wav;变异(改成 mp3)打红。旧文件里残留的 format 键被忽略。 3. Stats 接进 degrade 的 WARN(attempts/failures) 否则统计在生产日志里没有消费者。TestServiceDegradeLogsCounters 钉住; 变异(去掉两个字段)打红。 4. redactURL 兜底含 "@" 整体打码 解析失败/无 host 时原来原样返回,user:pass@ 会漏凭据。TestRedactURLCredentials 覆盖合法 URL 去 userinfo、非法含 @ 打码;变异(退回原样返回)打红。 另:关闭态零行为变化的**当前 tip 重跑**—— - handleSpeech 的 persona 解析改为只在语音服务实现 personaVoice 且 PrimaryActive 时才查库;未接 emotionTTS(既有链/stub)时零额外 I/O、ctx 不改、音频字节相同。 用例 TestSpeechEndpointDisabledVoiceDoesNotReadPersona(含对照组证明闸不是恒关); 变异(去掉闸)打红。 - voice.go 在 dev 基线里本就有 env.PersonaID 解析(无人格解析新增 I/O),本次未改。 Config.Normalized 去掉未使用的 logger 参数。go build/vet/test(含 -race)全绿; 真机 live 复测:cyrene 264104 B、furina 221194 B。 --- cmd/cyrene/wire_voice_tts.go | 2 - internal/config/runtimeconfig/voicetts.go | 9 +- .../config/runtimeconfig/voicetts_test.go | 16 ++- internal/runtime/voice/tts/client.go | 47 +++++-- internal/runtime/voice/tts/client_test.go | 16 +-- internal/runtime/voice/tts/config.go | 57 ++++---- internal/runtime/voice/tts/redact_test.go | 123 ++++++++++++++++++ internal/runtime/voice/tts/selection_test.go | 10 +- internal/runtime/voice/tts/service.go | 9 +- internal/runtime/voice/tts/service_test.go | 31 +++++ internal/services/gateway/audio.go | 16 ++- .../services/gateway/voice_emotiontts_test.go | 66 ++++++++++ 12 files changed, 333 insertions(+), 69 deletions(-) create mode 100644 internal/runtime/voice/tts/redact_test.go diff --git a/cmd/cyrene/wire_voice_tts.go b/cmd/cyrene/wire_voice_tts.go index 4430c8b..f9db7aa 100644 --- a/cmd/cyrene/wire_voice_tts.go +++ b/cmd/cyrene/wire_voice_tts.go @@ -45,7 +45,6 @@ func (a *app) voiceForGateway() gateway.VoiceService { "base_url", client.RedactedBaseURL(), "personas", len(cfg.Personas), "default_set", cfg.Default != "", - "format", cfg.Format, "timeout_sec", int(cfg.Timeout/time.Second), "config", runtimeconfig.VoiceTTSConfigRelPath) return service @@ -84,7 +83,6 @@ func (a *app) voiceTTSConfig() (voicetts.Config, bool) { BaseURL: tuning.BaseURL, Personas: tuning.Personas, Default: tuning.Default, - Format: tuning.Format, Timeout: time.Duration(tuning.TimeoutSec) * time.Second, }, true } diff --git a/internal/config/runtimeconfig/voicetts.go b/internal/config/runtimeconfig/voicetts.go index fc4e4f5..8f1018d 100644 --- a/internal/config/runtimeconfig/voicetts.go +++ b/internal/config/runtimeconfig/voicetts.go @@ -94,11 +94,6 @@ type VoiceTTSTuning struct { // 保留字段只为在启动日志里明确告诉运维"这一键不再生效,请改用 personas", // 而不是让它被 json 静默丢弃。新配置**不要**写它。 Character string `json:"character,omitempty"` - // Format 是请求格式,只接受 wav/mp3/ogg。空 = 代码默认 wav。 - // - // 注意:上游产物**恒为 WAV**(它按 `response_format` 自报 media_type,但字节是 - // WAV)⇒ 真正下发的格式一律由字节嗅探裁决,这一键只影响请求参数。 - Format string `json:"format,omitempty"` // TimeoutSec 是单次合成超时(**秒**)。零/负 = 没配 ⇒ 代码默认 60 秒。 // // 用秒而不是 Go 时长字面量:这个文件是给人改的,写 `60` 比要求记住 `"60s"` @@ -106,6 +101,10 @@ type VoiceTTSTuning struct { TimeoutSec int `json:"timeoutSec,omitempty"` } +// 本文件**刻意没有** `format` 键:上游只拿 `response_format` 改它自报的 media_type, +// 产物恒 `.wav` 且无转码,我方又只按字节嗅探裁决 ⇒ 那个键不改变任何交付字节, +// 是假开关。请求体里它由 `tts.requestFormat` 固定为 wav。 + // EnabledOn 报告这一级是否被显式打开(nil = 没配 ⇒ 不启用)。 func (t VoiceTTSTuning) EnabledOn() bool { return t.Enabled != nil && *t.Enabled } diff --git a/internal/config/runtimeconfig/voicetts_test.go b/internal/config/runtimeconfig/voicetts_test.go index 107393b..a3f5558 100644 --- a/internal/config/runtimeconfig/voicetts_test.go +++ b/internal/config/runtimeconfig/voicetts_test.go @@ -29,7 +29,7 @@ func TestLoadVoiceTTSTuning(t *testing.T) { // ③ 合法(新形状:personas 映射 + default;旧的全局 character 不再参与) body := `{"enabled":true,"baseUrl":"http://127.0.0.1:9880", "personas":{"cyrene":"char_84fb88a3","furina":"char_5314489e"}, - "default":"char_84fb88a3","format":"wav","timeoutSec":45}` + "default":"char_84fb88a3","timeoutSec":45}` if err := os.WriteFile(path, []byte(body), 0o600); err != nil { t.Fatalf("write: %v", err) } @@ -37,7 +37,7 @@ func TestLoadVoiceTTSTuning(t *testing.T) { if err != nil { t.Fatalf("合法文件: %v", err) } - if !tuning.EnabledOn() || tuning.Format != "wav" || tuning.TimeoutSec != 45 { + if !tuning.EnabledOn() || tuning.TimeoutSec != 45 { t.Fatalf("读取结果不对: %+v", tuning) } if len(tuning.Personas) != 2 || tuning.Personas["furina"] != "char_5314489e" { @@ -47,6 +47,18 @@ func TestLoadVoiceTTSTuning(t *testing.T) { t.Fatalf("default = %q", tuning.Default) } + // ③c 旧文件里若还留着 `format`,它**不再有任何效果**(结构体里没有这个键, + // json 当作未知字段忽略):加载仍成功,且交付格式只由字节嗅探决定。 + if err := os.WriteFile(path, []byte(`{"enabled":true,"format":"mp3","personas":{"cyrene":"char_84fb88a3"}}`), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + if tuning, err = LoadVoiceTTSTuning(path); err != nil { + t.Fatalf("带废弃 format 键的文件不该解不开: %v", err) + } + if !tuning.EnabledOn() || tuning.Personas["cyrene"] != "char_84fb88a3" { + t.Fatalf("废弃键影响了其余字段: %+v", tuning) + } + // ③b 旧的全局 character 仍能读出来(供启动日志提示"已废弃"),但它**不是** // personas/default 的一部分 —— 组合根绝不把它当发音人。 if err := os.WriteFile(path, []byte(`{"enabled":true,"character":"char_old"}`), 0o600); err != nil { diff --git a/internal/runtime/voice/tts/client.go b/internal/runtime/voice/tts/client.go index 94b0e6e..d0afafb 100644 --- a/internal/runtime/voice/tts/client.go +++ b/internal/runtime/voice/tts/client.go @@ -64,9 +64,10 @@ type speechPayload struct { Model string `json:"model"` // Input 是要合成的文本(UTF-8;见文件头的红线)。 Input string `json:"input"` - // Voice 是角色 id 或名字(`char_84fb88a3` / `Cyrene` 都认)。 + // Voice 是角色 id(由会话 persona 映射而来,每次请求现算)。 Voice string `json:"voice"` - // ResponseFormat 是请求格式;产物恒为 WAV,真实格式按字节嗅探。 + // ResponseFormat 恒为 `wav`(`requestFormat`):上游产物无转码、我方只按字节 + // 嗅探裁决,这个字段不可配置(见 requestFormat 的注释)。 ResponseFormat string `json:"response_format"` } @@ -110,7 +111,7 @@ func New(cfg Config, opts ...Option) *Client { for _, o := range opts { o(c) } - c.cfg = cfg.Normalized(c.log) + c.cfg = cfg.Normalized() if c.http == nil { c.http = &http.Client{Timeout: c.cfg.Timeout} } @@ -163,7 +164,7 @@ func (c *Client) Speech(ctx context.Context, character, text string) (Audio, err // speak 是 Speech 的主体:编码 → 发送 → 判状态 → 嗅探。失败原因已分类。 func (c *Client) speak(ctx context.Context, character, text string) (Audio, error) { - body, err := encodeSpeech(text, character, c.cfg.Format) + body, err := encodeSpeech(text, character) if err != nil { return Audio{}, err } @@ -193,12 +194,12 @@ func (c *Client) speak(ctx context.Context, character, text string) (Audio, erro // 🔴 全仓仅此一处构造请求体:`json.Encoder` 天然产出 UTF-8,且这里显式关掉 HTML // 转义,使正文里的 `<`/`>`/`&` 不会被改写成 `\u003c` 之类(内容逐字不变,下游 // 日志与音色匹配都依赖原文)。**绝不**改成字符串拼接或 GBK 转码(见文件头)。 -func encodeSpeech(text, character, format string) ([]byte, error) { +func encodeSpeech(text, character string) ([]byte, error) { payload := speechPayload{ Model: modelName, Input: text, Voice: character, - ResponseFormat: format, + ResponseFormat: requestFormat, } var buf bytes.Buffer enc := json.NewEncoder(&buf) @@ -225,11 +226,30 @@ func (c *Client) unreachable(ctx context.Context, endpoint string, err error) er return fmt.Errorf("%w: %s: %w", ErrUnreachable, reason, err) } -// upstreamStatus 把非 200 收敛成 ErrUpstream,并在日志里留下状态码与一小段 -// 响应片段(上游的错误体是它自己的 `{"detail":...}`;片段只用于排查,不含我方正文)。 +// upstreamStatus 把非 200 收敛成 ErrUpstream,并只记**元数据**(状态码、Content-Type +// 与响应字节数)—— 上游的错误体**一个字节都不进日志**。 +// +// 🔴 这条是隐私红线,不是风格问题:FastAPI 的 422 会把请求体里的 `input` **整段回显**, +// 实测报文形如 +// +// {"detail":[{"type":"missing","loc":["body","voice"],…, +// "input":{"input":"你好,我是苏梦,我有点难过。"}}]} +// +// 于是"把错误体前 512 字节打进 WARN"就等于**把她说的原话写进日志**。错误链里同样 +// 只有状态码(`TestSpeechRejectLogOmitsRequestText` 钉这一条,变异可打红)。 +// +// 字节数优先取 `Content-Length`;缺失时只做**有界**排空(上限 maxDrainBytes)得出 +// 一个下界,**不为数字节把整段读完**,读到的东西也绝不进日志。 func (c *Client) upstreamStatus(res *http.Response) error { - snippet, _ := io.ReadAll(io.LimitReader(res.Body, 512)) - c.warn("voice tts rejected", "status", res.StatusCode, "detail", string(snippet)) + size := res.ContentLength + if size < 0 { + drained, _ := io.Copy(io.Discard, io.LimitReader(res.Body, maxDrainBytes)) + size = drained + } + c.warn("voice tts rejected", + "status", res.StatusCode, + "content_type", res.Header.Get("Content-Type"), + "response_bytes", size) return fmt.Errorf("%w: status %d", ErrUpstream, res.StatusCode) } @@ -273,9 +293,16 @@ type Audio struct { // redactURL 把 baseURL 收敛成**可打日志**的形态:只保留 scheme 与 host, // 丢掉 userinfo/query(若运维在地址里塞了凭据,别把它写进日志)。 +// +// 解析失败或没有 host 时**不能**原样返回:`http://user:pass@` 这种不合法地址会 +// 把凭据整个写进日志。因此兜底判据是"含 `@` 就整体打码"(`@` 是 userinfo 的 +// 分隔符,出现在地址里只可能是凭据)。 func redactURL(raw string) string { u, err := url.Parse(raw) if err != nil || u.Host == "" { + if strings.Contains(raw, "@") { + return "" + } return raw } return u.Scheme + "://" + u.Host diff --git a/internal/runtime/voice/tts/client_test.go b/internal/runtime/voice/tts/client_test.go index 7d9e7f3..f577208 100644 --- a/internal/runtime/voice/tts/client_test.go +++ b/internal/runtime/voice/tts/client_test.go @@ -91,8 +91,9 @@ func TestSpeechSendsUTF8ChineseText(t *testing.T) { if gotPayload.Voice != DefaultCharacter { t.Errorf("voice = %q, want %q", gotPayload.Voice, DefaultCharacter) } - if gotPayload.ResponseFormat != DefaultFormat { - t.Errorf("response_format = %q, want %q", gotPayload.ResponseFormat, DefaultFormat) + // response_format 恒为 wav 且**不可配置**(上游产物无转码,见 requestFormat)。 + if gotPayload.ResponseFormat != "wav" { + t.Errorf("response_format = %q, want 硬编码的 wav", gotPayload.ResponseFormat) } // ③ 回发的格式由**字节嗅探**裁决,不是请求里那个字段。 if audio.Format != "wav" || len(audio.Data) == 0 { @@ -216,24 +217,21 @@ func TestSpeechGuards(t *testing.T) { // 🔴 它同时钉住一条**反向**口径:归一化**不得**给任何角色兜底 —— 代码里写死一个 // 默认发音人,正是主人点名的病害(换人格仍用老声音)。 func TestConfigNormalized(t *testing.T) { - got := Config{}.Normalized(discardLogger()) + got := Config{}.Normalized() if got.Enabled { t.Errorf("零值配置不该被悄悄启用") } - if got.BaseURL != DefaultBaseURL || got.Format != DefaultFormat || got.Timeout != DefaultTimeout { + if got.BaseURL != DefaultBaseURL || got.Timeout != DefaultTimeout { t.Fatalf("默认值不对: %+v", got) } if got.Default != "" || len(got.Personas) != 0 { t.Fatalf("归一化给发音人兜底了(红线:不许有代码默认角色): %+v", got) } - if custom := (Config{BaseURL: " http://127.0.0.1:9880/ ", Format: "MP3"}).Normalized(discardLogger()); custom.BaseURL != DefaultBaseURL || custom.Format != "mp3" { + if custom := (Config{BaseURL: " http://127.0.0.1:9880/ "}).Normalized(); custom.BaseURL != DefaultBaseURL { t.Errorf("归一化不对: %+v", custom) } - if bad := (Config{Format: "exe"}).Normalized(discardLogger()); bad.Format != DefaultFormat { - t.Errorf("非法格式应回落默认: %+v", bad) - } // persona/character 两端的空白被清掉;空 personaId 的键被丢弃。 - trimmed := (Config{Personas: map[string]string{" a ": " char_1 ", "": "char_2"}, Default: " char_3 "}).Normalized(discardLogger()) + trimmed := (Config{Personas: map[string]string{" a ": " char_1 ", "": "char_2"}, Default: " char_3 "}).Normalized() if trimmed.Personas["a"] != "char_1" || len(trimmed.Personas) != 1 || trimmed.Default != "char_3" { t.Fatalf("映射/默认值没有被归一化: %+v", trimmed) } diff --git a/internal/runtime/voice/tts/config.go b/internal/runtime/voice/tts/config.go index 3c68b26..bf95abb 100644 --- a/internal/runtime/voice/tts/config.go +++ b/internal/runtime/voice/tts/config.go @@ -22,14 +22,14 @@ // 做一个假开关** —— 因此 `Config` 刻意不含 `speed`。同理不含 `emotion`/`tone`: // 那两样上游自己推,我方传什么都是噪音。 // -// # 一次只暴露三样 +// # 对外只有两样 // -// 对调用方只暴露 `text`(必填)、角色(默认 `Cyrene` 的 `char_84fb88a3`)与 -// `format`(默认 wav)。其余全部是代码默认值或面板文件里的运维旋钮。 +// 对调用方只暴露 `text`(必填)与角色(由会话 persona 映射而来)。**没有 `format`**: +// 上游只拿它改自报的 media_type,产物恒 `.wav` 无转码,我方只按字节嗅探裁决 ⇒ +// 那个键不改变任何交付字节(假开关)。其余全是面板文件里的运维旋钮或代码默认值。 package tts import ( - "log/slog" "strings" "time" ) @@ -46,30 +46,39 @@ const ( // `Config.Personas` / `Config.Default` 显式给出(见 Resolve)。 DefaultCharacter = "char_84fb88a3" - // DefaultFormat 是默认请求格式。服务端产物**恒为 WAV**(它把 .wav 落盘后用 - // `FileResponse` 回发,`response_format` 只影响它自报的 media_type),因此默认 - // wav;实际下发的格式仍由**字节嗅探**裁决(见 audio.go),不信任这个字段。 - DefaultFormat = "wav" - - // DefaultTimeout 是单次合成超时。实测一次中文合成约 4~6 秒(它内部还要先跑一次 - // LLM 情绪分析 + 素材匹配),故默认留足余量;超时即失败并回落既有链。 + // DefaultTimeout 是单次合成超时。实测一次中文合成约 4~8 秒(它内部还要先跑一次 + // LLM 情绪分析 + 素材匹配),故默认留足余量;超时即失败并按既有口径回落。 DefaultTimeout = 60 * time.Second // defaultMaxAudioBytes 限制读回的音频体积,使异常/畸形响应无法耗尽内存 // (32 MiB 远超一句话的语音,实测 5 秒语音约 100~200 KB)。 defaultMaxAudioBytes = 32 << 20 + // maxDrainBytes 是错误响应体的**有界**排空上限:只用来得出一个字节数下界, + // 读到的东西直接丢弃、绝不进日志(见 upstreamStatus 的隐私红线)。 + maxDrainBytes = 4096 + // speechPath 是 OpenAI 兼容合成端点(已实测可用:200 / RIFF-WAVE)。 speechPath = "/v1/audio/speech" // modelName 是上游要求的固定模型名(该服务只认 `emotionTTS`)。 modelName = "emotionTTS" + + // requestFormat 是请求体里 `response_format` 的**固定值**。 + // + // 🔴 它刻意**不可配置**:上游只拿它改 `FileResponse` 的 media_type,产物恒为 + // `.wav` 且**无转码**(`tts_router.py:437-438`),我方又从不读 `Content-Type` + // (格式一律由 `sniffAudio` 按字节裁决)⇒ `wav`/`mp3`/`ogg` 交付出去的字节与 + // 响应头**完全相同**。做成配置键就是一个假开关(主人对假开关零容忍)。 + requestFormat = "wav" ) // Config 是 emotionTTS 客户端的运行期旋钮(由面板文件 // `$DATA_DIR/config/voice-tts.json` 映射而来;零新 `.env` 键)。 // // 🔴 刻意**没有** speed / emotion / tone 三项,理由见包注释(上游硬编码或自推)。 +// 🔴 刻意**没有** format:上游只拿 `response_format` 改它自报的 media_type,产物 +// 恒为 `.wav` 无转码,我方又只按字节嗅探裁决 ⇒ 那个键不改变任何交付字节,是假开关。 // 🔴 也**没有全局 character**:发音人只能由 `Personas`(按会话角色映射)或 // `Default`(仅服务"解析不到 personaId")给出 —— 主人 2026-10-26 的硬要求 // 「TTS 的人物选择要绑定后端的角色,不然换了人格还是用老角色的声音」。 @@ -87,24 +96,16 @@ type Config struct { // 该出口本身没有会话)时使用的发音人。它**不**服务"配了 Personas 但没有 // 这个 persona 的键"那种情况 —— 那是 Resolve 的拒绝分支(主人点名的病害)。 Default string - // Format 是请求格式(`response_format`);空 = DefaultFormat。只接受 - // wav/mp3/ogg —— 且**只用于请求**,回发的真实格式一律按字节嗅探。 - Format string // Timeout 是单次合成超时;<=0 = DefaultTimeout。 Timeout time.Duration } -// supportedFormats 是允许写进 Format 的取值(上游 `response_format` 只用于自报 -// media_type;产物恒为 WAV)。写别的值不是"更灵活",而是让下游拿到一个与字节 -// 不符的标签 —— 因此这里**拒绝**而不是静默放行。 -var supportedFormats = map[string]bool{"wav": true, "mp3": true, "ogg": true} - -// Normalized 返回填好默认值、去掉首尾空白的配置。非法 Format 回落到默认值并 -// 通过 log 记一条 WARN(**不让它停摆** —— 一个写错的格式不该关掉整个语音面)。 +// Normalized 返回填好默认值、去掉首尾空白的配置。 // // 它**不**给 `Personas`/`Default` 补任何默认角色:发音人只有两种合法来源 -// (显式映射 / 显式 default),代码里的兜底值就是主人担心的病害本身。 -func (c Config) Normalized(log *slog.Logger) Config { +// (显式映射 / 显式 default),代码里的兜底值就是主人担心的病害本身。它也不接受 +// logger:当前没有任何"需要告警的回落"(格式已不是配置项,见 requestFormat)。 +func (c Config) Normalized() Config { out := c out.BaseURL = strings.TrimRight(strings.TrimSpace(out.BaseURL), "/") if out.BaseURL == "" { @@ -112,16 +113,6 @@ func (c Config) Normalized(log *slog.Logger) Config { } out.Personas = normalizePersonas(out.Personas) out.Default = strings.TrimSpace(out.Default) - out.Format = strings.ToLower(strings.TrimSpace(out.Format)) - if out.Format == "" { - out.Format = DefaultFormat - } else if !supportedFormats[out.Format] { - if log != nil { - log.Warn("unsupported tts format, fell back to default", - "format", out.Format, "default", DefaultFormat) - } - out.Format = DefaultFormat - } if out.Timeout <= 0 { out.Timeout = DefaultTimeout } diff --git a/internal/runtime/voice/tts/redact_test.go b/internal/runtime/voice/tts/redact_test.go new file mode 100644 index 0000000..3d38af5 --- /dev/null +++ b/internal/runtime/voice/tts/redact_test.go @@ -0,0 +1,123 @@ +package tts + +// 本文件锁定**日志侧**的两条口径(复审 r1 的 P2-2 / nit,都是"CI 抓不到"的那类): +// +// 1. 🔴 WARN / 错误链里**绝不能出现她的正文**。上游(FastAPI)的 422 会把请求体 +// 里的 `input` 整段回显,所以「把错误体前 512 字节打进日志」= 把她的原话写进 +// 日志。这里用一台**回显正文**的假上游 + 一个接日志缓冲区的 logger 来抓它。 +// 2. `redactURL` 的兜底路径:地址里含 `@`(userinfo)时必须整体打码,哪怕 URL +// 本身不合法 —— 否则 `user:pass@` 会把凭据原样写进日志。 + +import ( + "bytes" + "context" + "errors" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// captureLogger 返回一个把日志写进内存缓冲的 logger(断言用)。 +func captureLogger() (*slog.Logger, *bytes.Buffer) { + var buf bytes.Buffer + return slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})), &buf +} + +// TestSpeechRejectLogOmitsRequestText 是隐私红线的用例:上游回 422 并**回显请求体** +// (FastAPI 的真实行为),日志里因此**一个正文词都不许出现**,只许有元数据。 +// +// 变异:把错误体重新塞回 WARN(`"detail", string(snippet)`)⇒ 本用例必红。 +func TestSpeechRejectLogOmitsRequestText(t *testing.T) { + log, buf := captureLogger() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnprocessableEntity) + // 这就是复审在真服务上抓到的形状:input 被整段回显。 + _, _ = w.Write([]byte(`{"detail":[{"type":"missing","loc":["body","voice"],` + + `"msg":"Field required","input":{"model":"emotionTTS","input":"` + chineseSpeech + `"}}]}`)) + })) + t.Cleanup(srv.Close) + client := New(Config{Enabled: true, BaseURL: srv.URL}, WithLogger(log)) + + _, err := client.Speech(context.Background(), DefaultCharacter, chineseSpeech) + if !errors.Is(err, ErrUpstream) { + t.Fatalf("err = %v, want ErrUpstream", err) + } + + logs := buf.String() + // ① 日志里不得出现正文的任何一段(整句、半句、单个关键词都算泄漏)。 + for _, leak := range []string{chineseSpeech, "苏梦", "心情特别好", "你好"} { + if strings.Contains(logs, leak) { + t.Fatalf("日志里出现了请求正文(%q)—— 隐私红线:\n%s", leak, logs) + } + } + // ② 也不得出现上游错误体的结构键(那正是"把 body 打出来"的痕迹)。 + if strings.Contains(logs, "detail") || strings.Contains(logs, "loc") { + t.Fatalf("日志里出现了上游错误体内容:\n%s", logs) + } + // ③ 该有的元数据必须在:状态码 + 响应字节数(排查靠它们,不靠正文)。 + if !strings.Contains(logs, "status=422") { + t.Fatalf("日志缺状态码:\n%s", logs) + } + if !strings.Contains(logs, "response_bytes=") { + t.Fatalf("日志缺响应字节数:\n%s", logs) + } + // ④ 错误串本身也不许带正文(调用方可能把它写日志)。 + if strings.Contains(err.Error(), "苏梦") || strings.Contains(err.Error(), chineseSpeech) { + t.Fatalf("错误串里出现了正文: %v", err) + } +} + +// TestSpeechRejectLogUsesContentLength 钉住"字节数优先取 Content-Length": +// 有长度时不必读响应体就能报出大小(不为数字节把整段读完)。 +func TestSpeechRejectLogUsesContentLength(t *testing.T) { + log, buf := captureLogger() + body := `{"detail":"nope"}` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Length", "17") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + client := New(Config{Enabled: true, BaseURL: srv.URL}, WithLogger(log)) + + if _, err := client.Speech(context.Background(), DefaultCharacter, "你好"); !errors.Is(err, ErrUpstream) { + t.Fatalf("err = %v, want ErrUpstream", err) + } + logs := buf.String() + if !strings.Contains(logs, "response_bytes=17") { + t.Fatalf("没有采用 Content-Length: 17:\n%s", logs) + } + if strings.Contains(logs, "nope") { + t.Fatalf("日志里出现了响应体内容:\n%s", logs) + } +} + +// TestRedactURLCredentials 钉住 `redactURL` 的凭据兜底(复审 r1 的 nit)。 +func TestRedactURLCredentials(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"普通地址不动", "http://127.0.0.1:9880", "http://127.0.0.1:9880"}, + {"合法 URL 的 userinfo 被丢掉", "http://user:pass@127.0.0.1:9880", "http://127.0.0.1:9880"}, + {"非法 URL 含 @ ⇒ 整体打码", "http://user:pass@", ""}, + {"无 scheme 含 @ ⇒ 整体打码", "user:pass@host", ""}, + {"空串原样", "", ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := redactURL(c.in) + if got != c.want { + t.Fatalf("redactURL(%q) = %q, want %q", c.in, got, c.want) + } + if strings.Contains(got, "pass") { + t.Fatalf("凭据漏进可打日志的地址: %q", got) + } + }) + } +} diff --git a/internal/runtime/voice/tts/selection_test.go b/internal/runtime/voice/tts/selection_test.go index 355a788..27343b3 100644 --- a/internal/runtime/voice/tts/selection_test.go +++ b/internal/runtime/voice/tts/selection_test.go @@ -27,7 +27,7 @@ func selectionCfg() Config { // TestResolveMapsPersonaToCharacter 分支①:有映射 ⇒ 用映射到的角色(两个 persona // 各自映射到不同角色,绝不互相串)。 func TestResolveMapsPersonaToCharacter(t *testing.T) { - cfg := selectionCfg().Normalized(discardLogger()) + cfg := selectionCfg().Normalized() got, err := cfg.Resolve("cyrene") if err != nil || got.Character != "char_84fb88a3" || got.Source != SourceMapped { @@ -45,7 +45,7 @@ func TestResolveMapsPersonaToCharacter(t *testing.T) { // TestResolveUnmappedPersonaRefuses 分支④(红线):配了 personas 但**没有这个 // persona 的键** ⇒ 拒绝,**不看 default**、也不借用任何别的角色。 func TestResolveUnmappedPersonaRefuses(t *testing.T) { - cfg := selectionCfg().Normalized(discardLogger()) + cfg := selectionCfg().Normalized() got, err := cfg.Resolve("stranger") if !errors.Is(err, ErrPersonaUnmapped) { @@ -63,7 +63,7 @@ func TestResolveUnmappedPersonaRefuses(t *testing.T) { // TestResolveUnresolvedPersonaUsesDefault 分支②:personaId 解析不到(空串)⇒ 用 // default(配了才用)。 func TestResolveUnresolvedPersonaUsesDefault(t *testing.T) { - cfg := selectionCfg().Normalized(discardLogger()) + cfg := selectionCfg().Normalized() got, err := cfg.Resolve("") if err != nil || got.Character != "char_fallback" || got.Source != SourceDefault { t.Fatalf("空 persona ⇒ %+v err=%v, want default", got, err) @@ -73,7 +73,7 @@ func TestResolveUnresolvedPersonaUsesDefault(t *testing.T) { // TestResolveUnresolvedPersonaWithoutDefaultRefuses 分支②的另一半:没配 default ⇒ // 不合成(可分类错误),绝不自己猜一个角色。 func TestResolveUnresolvedPersonaWithoutDefaultRefuses(t *testing.T) { - cfg := Config{Enabled: true, Personas: map[string]string{"cyrene": "char_84fb88a3"}}.Normalized(discardLogger()) + cfg := Config{Enabled: true, Personas: map[string]string{"cyrene": "char_84fb88a3"}}.Normalized() if got, err := cfg.Resolve(""); !errors.Is(err, ErrNoCharacter) || got.Character != "" { t.Fatalf("空 persona + 无 default ⇒ %+v err=%v, want ErrNoCharacter", got, err) } @@ -82,7 +82,7 @@ func TestResolveUnresolvedPersonaWithoutDefaultRefuses(t *testing.T) { // TestResolveIsNotCachedAcrossPersonas 是主人担心那件事的**回归锁**:选角是纯函数, // 同一份配置先后解析两个 persona,第二次绝不沿用第一次的结果。 func TestResolveIsNotCachedAcrossPersonas(t *testing.T) { - cfg := selectionCfg().Normalized(discardLogger()) + cfg := selectionCfg().Normalized() first, err := cfg.Resolve("cyrene") if err != nil { t.Fatalf("first: %v", err) diff --git a/internal/runtime/voice/tts/service.go b/internal/runtime/voice/tts/service.go index 681544e..81899a8 100644 --- a/internal/runtime/voice/tts/service.go +++ b/internal/runtime/voice/tts/service.go @@ -158,15 +158,20 @@ func (s *Service) fallback(ctx context.Context, text string) (mvoice.Audio, stri // degrade 记录一次 emotionTTS 降级:未启用是 DEBUG(正常状态),其余是 WARN // (异常状态,但已被回落吸收)。日志只带文本长度,**绝不带正文**。 +// +// attempts / failures 也挂在这条 WARN 上:`Stats()` 若没有消费者,生产日志里就永远 +// 看不到"降级过几次",那这个计数等于没做(复审 r1 的 P2-3)。 func (s *Service) degrade(ctx context.Context, sel Selection, text string, err error) { + stats := s.Stats() if ctx.Err() != nil { // 回合被取消/超时的情形已由 Client 记过(unreachable),这里只补一句 // 「因此这次没声音」,避免同一条失败刷两条 WARN。 - s.log.Debug("voice tts skipped after cancel", "provider", ProviderName, "text_len", len(text)) + s.log.Debug("voice tts skipped after cancel", "provider", ProviderName, + "text_len", len(text), "attempts", stats.Attempts, "failures", stats.Failures) return } s.log.Warn("voice tts degraded to fallback chain", "provider", ProviderName, "persona", sel.PersonaID, "character", sel.Character, "source", string(sel.Source), - "text_len", len(text), "err", err) + "text_len", len(text), "attempts", stats.Attempts, "failures", stats.Failures, "err", err) } diff --git a/internal/runtime/voice/tts/service_test.go b/internal/runtime/voice/tts/service_test.go index 6086273..f341eee 100644 --- a/internal/runtime/voice/tts/service_test.go +++ b/internal/runtime/voice/tts/service_test.go @@ -21,6 +21,7 @@ import ( "log/slog" "net/http" "net/http/httptest" + "strings" "sync" "testing" @@ -249,6 +250,36 @@ func TestServiceFallsBackWhenUpstreamRejects(t *testing.T) { } } +// TestServiceDegradeLogsCounters 钉住 P2-3:降级那条 WARN 必须带上 attempts/failures —— +// 否则 `Stats()` 在生产日志里没有任何消费者,等于没做。 +// +// 变异:从 degrade 的 WARN 里去掉这两个字段 ⇒ 本用例红。 +func TestServiceDegradeLogsCounters(t *testing.T) { + rec := &recorder{wav: false, status: http.StatusInternalServerError} + srv := httptest.NewServer(rec.handler(t)) + t.Cleanup(srv.Close) + log, buf := captureLogger() + cfg := personaCfg() + cfg.Enabled = true + cfg.BaseURL = srv.URL + svc := NewService(New(cfg, WithLogger(discardLogger())), &fakeChain{}, WithServiceLogger(log)) + + if _, _, err := svc.TTSWithProvider(WithPersonaID(context.Background(), "cyrene"), chineseSpeech); err != nil { + t.Fatalf("主路失败应回落既有链,不该整体失败: %v", err) + } + logs := buf.String() + if !strings.Contains(logs, "failures=1") { + t.Fatalf("降级 WARN 里没有失败计数:\n%s", logs) + } + if !strings.Contains(logs, "attempts=1") { + t.Fatalf("降级 WARN 里没有尝试计数:\n%s", logs) + } + // 计数与正文同屏的风险同样要守住:这条 WARN 也不许带正文。 + if strings.Contains(logs, "苏梦") { + t.Fatalf("降级 WARN 里出现了正文:\n%s", logs) + } +} + // TestServiceDisabledIsTransparent 钉住"未启用 = 行为逐字不变":底链原样服务, // 一次都不碰 emotionTTS 的 HTTP。 func TestServiceDisabledIsTransparent(t *testing.T) { diff --git a/internal/services/gateway/audio.go b/internal/services/gateway/audio.go index 64a5a6d..3162d73 100644 --- a/internal/services/gateway/audio.go +++ b/internal/services/gateway/audio.go @@ -41,6 +41,14 @@ type speechRequest struct { SessionID string `json:"sessionId,omitempty"` } +// personaVoice 是"会按会话 persona 选发音人"的语音服务的**可选**扩展。只有 +// emotionTTS 门面(`*voicetts.Service`)实现它;既有链、stub 服务都不实现 ⇒ +// 未接 emotionTTS 时本片不为它额外查库,关闭态与引入之前逐字同行为。 +type personaVoice interface { + // PrimaryActive 报告"按 persona 选发音"这一级是否真的在生效。 + PrimaryActive() bool +} + // handleTranscribe 对上传的音频执行 STT 并返回识别出的 // 文本。它是原生客户端的 REST 来源,绕过 4KB WebSocket // 帧上限(该上限会截断 base64 音频块)。 @@ -110,7 +118,13 @@ func (s *Server) handleSpeech(w http.ResponseWriter, r *http.Request) { // 发音人按**本次请求的会话**所绑定的人格解析(切片 M35)。空 sessionId 或 // 会话无绑定 ⇒ personaID 为空 ⇒ 语音面走 default 分支(没有 default 就不合成)。 // 解析只做一次、只服务本次请求,绝不在注入时算一次并缓存成全局。 - ctx = voicetts.WithPersonaID(ctx, s.envResolver.sessionPersona(ctx, req.SessionID)) + // + // 🔴 只有"真的会按 persona 选发音"的语音服务才做这次库读取:未启用 emotionTTS + // 时注入的是既有链本身,此时本函数**不额外查库、不改 ctx** —— 关闭态与引入本 + // 切片之前逐字同行为(复审要求自证的那一条)。 + if pv, ok := s.voice.(personaVoice); ok && pv.PrimaryActive() { + ctx = voicetts.WithPersonaID(ctx, s.envResolver.sessionPersona(ctx, req.SessionID)) + } audi, rung, err := ttsWithProvider(ctx, s.voice, req.Text) if err != nil { s.auditClaims(r, "audio.speech", "error") diff --git a/internal/services/gateway/voice_emotiontts_test.go b/internal/services/gateway/voice_emotiontts_test.go index ccbd2e5..aac1329 100644 --- a/internal/services/gateway/voice_emotiontts_test.go +++ b/internal/services/gateway/voice_emotiontts_test.go @@ -229,6 +229,72 @@ func TestVoiceOutputFrameVoiceFollowsTurnPersona(t *testing.T) { } } +// TestSpeechEndpointDisabledVoiceDoesNotReadPersona 是**关闭态零行为变化**的自证 +// (复审要求在当前 tip 上重跑的那一条,而我这一版动了 `gateway/audio.go`): +// +// 注入的语音服务不是"按 persona 选发音"的那一类(既有链 / stub)时,语音合成 +// **一次都不查会话人格** —— 与引入本切片之前逐字同行为;反之(真 emotionTTS 门面) +// 才会查,说明这个闸不是"永远不查"。 +// +// 变异:去掉 handleSpeech 里的 `pv.PrimaryActive()` 闸 ⇒ 本用例第一段红。 +func TestSpeechEndpointDisabledVoiceDoesNotReadPersona(t *testing.T) { + env := newEnv(t, nil, false) + stub := &audioStubVoice{ttsAudio: mvoice.Audio{Format: "wav", Data: fakeWAV("chain")}} + env.srv.SetVoice(stub) + counter := &countingHistory{fakeHistory: &fakeHistory{}} + env.srv.envResolver = turnEnvResolver{history: counter, log: env.srv.log} + token := env.token(t, auth.RoleOwner) + + resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/audio/speech", map[string]any{ + "text": "你好", "sessionId": "web:sub-1", + }, token) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, body = %s", resp.StatusCode, body) + } + if got := counter.Calls(); got != 0 { + t.Fatalf("非 persona 语音服务却查了 %d 次会话人格(关闭态行为变了)", got) + } + if string(body) != string(fakeWAV("chain")) { + t.Fatalf("关闭态的音频不是既有链产物: %q", string(body)) + } + + // 对照:接上真的 emotionTTS 门面后,同一个端点会查一次人格(闸不是恒关)。 + env2 := newEnv(t, nil, false) + env2.srv.SetVoice(emotionTTSService(t, &vocalRecorder{}, voicetts.Config{Personas: personaCharacters}, &silentBase{})) + counter2 := &countingHistory{fakeHistory: &fakeHistory{}} + env2.srv.envResolver = turnEnvResolver{history: counter2, log: env2.srv.log} + if resp, body := doReq(t, http.MethodPost, env2.ts.URL+"/api/v1/audio/speech", map[string]any{ + "text": "你好", "sessionId": "web:sub-1", + }, env.token(t, auth.RoleOwner)); resp.StatusCode != http.StatusOK { + t.Fatalf("对照组 status = %d, body = %s", resp.StatusCode, body) + } + if got := counter2.Calls(); got != 1 { + t.Fatalf("启用 emotionTTS 后应查 1 次人格, got %d", got) + } +} + +// countingHistory 记录 SessionPersona 被查了几次(同时满足 HistoryStore 的其余方法)。 +type countingHistory struct { + *fakeHistory + mu sync.Mutex + calls int +} + +// SessionPersona 计数并回一个固定的 persona。 +func (h *countingHistory) SessionPersona(context.Context, string) (string, error) { + h.mu.Lock() + h.calls++ + h.mu.Unlock() + return "furina", nil +} + +// Calls 返回查询次数。 +func (h *countingHistory) Calls() int { + h.mu.Lock() + defer h.mu.Unlock() + return h.calls +} + // asString 安全地取一个 string 值(帧载荷是 map[string]any)。 func asString(v any) string { s, _ := v.(string) From e684061e56f66cf5a56facbf9f3a87192ba09610 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 26 Oct 2026 13:21:44 +0800 Subject: [PATCH 4/6] =?UTF-8?q?fix(voice):=20=E4=B8=BB/=E5=AD=90=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E4=BC=9A=E8=AF=9D=E7=9A=84=E5=8F=91=E9=9F=B3=E4=BA=BA?= =?UTF-8?q?=E8=B7=9F=E3=80=8C=E7=94=9F=E6=95=88=E4=BA=BA=E6=A0=BC=E3=80=8D?= =?UTF-8?q?=E8=B5=B0=EF=BC=88r2=20P2-1=EF=BC=89+=20=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E7=A0=81=E5=8F=AF=E5=8C=BA=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2-1(实质):主会话(web)的 persona_id 恒为空且无重绑路径,而"空"的真实语义是 「继承全局默认人格」(loop/persona.go 回退 Options.PersonaID;前端 lib/persona.ts 的 bound ?? defaultPersona(personas) 是同一口径)。此前只有 TTS 把空读成"没有生效人格" ⇒ 落到 voice-tts.json 的 default:主会话聊天是 furina、声音却是 default 那个人, 换全局人格后也不跟声 —— 正是主人抱怨的那件事,且发生在唯一能给普通聊天出声的路径。 修法: - turnEnvResolver 新增 resolvePersona/effectivePersona(绑定 → 全局默认人格 → ""), 默认人格扫描抽成 defaultPersonaIDFrom 一处(*Server.defaultPersonaID 转调它); - ownerWithPersona 复用**同一次**会话读取同时给出 loop 用的 binding 与呈现用的 effective —— WS 语音回合为选音色不新增任何 I/O; - REST:现有闸内改用 effectivePersona;WS:voice.go 那次 env 解析处取一次传给 sink。 因此 `default` 只剩"连默认人格都没有"这一种用途。 - service.go 那句"换人格后用老角色声音不可能发生"收回为限定说法:本包无缓存、无兜底 角色、未映射即拒绝,但**调用方必须传生效人格**(网关两处已修)。 回归锁:voice_persona_inherit_test.go 复现复审用例(名字不同、两条口各一条)—— 未绑定会话 + 全局默认 furina + default 指向 cyrene ⇒ 必须用 furina 的声音; 变异(effectivePersona 退回「空 = 无」)实测两条都红,报文与复审一致。 建议 3(顺手做):persona 未映射时状态码仍 500(服务端配置缺失),但错误码换成 tts_persona_unmapped / tts_no_character,不再与一般合成失败混成一个码; 用例 TestSpeechEndpointPersonaUnmappedErrorCode 钉住。 P2-2(nit):voicetts.go 开头删掉「格式、」两字(该键已删除)。 go build ./... + go vet ./... + go test(含 -race)全绿。 --- internal/config/runtimeconfig/voicetts.go | 2 +- internal/runtime/voice/tts/service.go | 12 +- internal/services/gateway/audio.go | 34 ++++- internal/services/gateway/persona_env.go | 77 +++++++++- internal/services/gateway/voice.go | 12 +- .../gateway/voice_persona_inherit_test.go | 142 ++++++++++++++++++ 6 files changed, 259 insertions(+), 20 deletions(-) create mode 100644 internal/services/gateway/voice_persona_inherit_test.go diff --git a/internal/config/runtimeconfig/voicetts.go b/internal/config/runtimeconfig/voicetts.go index 8f1018d..1f61e6a 100644 --- a/internal/config/runtimeconfig/voicetts.go +++ b/internal/config/runtimeconfig/voicetts.go @@ -3,7 +3,7 @@ // `$DATA_DIR/config/`;调优/门槛/上限/开关类参数走这里 + 代码默认值」)。 // // 为什么需要它:把半云端的 emotionTTS 接成她的一级发音人,需要几项运维旋钮 —— -// 开关、地址、**角色映射**、格式、超时;而 `.env` 已被主人冻结(**零新 env 键**, +// 开关、地址、**角色映射**、超时;而 `.env` 已被主人冻结(**零新 env 键**, // design/13 §2.1),所以它们落在这里,与 `memory-tidy.json` / `backthink.json` // 同一家族口径。 // diff --git a/internal/runtime/voice/tts/service.go b/internal/runtime/voice/tts/service.go index 81899a8..4d4aacc 100644 --- a/internal/runtime/voice/tts/service.go +++ b/internal/runtime/voice/tts/service.go @@ -16,9 +16,17 @@ package tts // 记一条带来源的 WARN + 计数(`Client.Stats`),然后回落既有链;既有链再失败才算 // 整体失败,而那时聊天正文早已发出/落库(TTS 是呈现层的增强,不是回复的前置条件)。 // -// 🔴 **发音人按当次请求的 persona 现算**(主人 2026-10-26 的硬要求):每次 +// 🔴 **发音人按当次请求的生效人格现算**(主人 2026-10-26 的硬要求):每次 // `TTSWithProvider` 都从 ctx 取 personaId、经 `Config.Resolve` 得出 character, -// **没有任何跨请求缓存**。因此"换人格后用老角色的声音"不可能发生。 +// **没有任何跨请求缓存**。 +// +// 补充限定(复审 r2 P2-1 要求收回原来的绝对说法):本包看到的 personaId 是**调用方 +// 解析出来的生效人格**(`chat_sessions.persona_id` → 全局默认人格)。"换人格后仍用 +// 老角色声音"在**本包内**不可能发生(无缓存 + 无兜底角色 + 未映射即拒绝);但若 +// 调用方把"会话未绑定"当成"没有角色"直接传空串,主会话就会落到配置里的 `default` +// —— 那不是本包能判断的,因此**调用方必须传生效人格**(网关两处已按此修, +// `turnEnvResolver.effectivePersona`)。`default` 的唯一合法用途只剩"连默认人格 +// 都没有"这一种情形。 import ( "context" diff --git a/internal/services/gateway/audio.go b/internal/services/gateway/audio.go index 3162d73..a22227b 100644 --- a/internal/services/gateway/audio.go +++ b/internal/services/gateway/audio.go @@ -2,6 +2,7 @@ package gateway import ( "encoding/json" + "errors" "net/http" "strconv" "strings" @@ -115,21 +116,27 @@ func (s *Server) handleSpeech(w http.ResponseWriter, r *http.Request) { if req.Instruction != "" { ctx = voice.WithTTSInstruction(ctx, req.Instruction) } - // 发音人按**本次请求的会话**所绑定的人格解析(切片 M35)。空 sessionId 或 - // 会话无绑定 ⇒ personaID 为空 ⇒ 语音面走 default 分支(没有 default 就不合成)。 + // 发音人按**本次请求的会话**的**生效人格**解析(切片 M35;复审 r2 P2-1): + // 会话绑定 → 全局默认人格 → 都没有才是空。空不等于"没有角色" —— 主会话 + // (`web`)的 persona_id 恒为空且没有重绑路径,把它当空会让主会话的声音不跟 + // 全局人格走(主人抱怨的正是这件事)。 + // // 解析只做一次、只服务本次请求,绝不在注入时算一次并缓存成全局。 // // 🔴 只有"真的会按 persona 选发音"的语音服务才做这次库读取:未启用 emotionTTS // 时注入的是既有链本身,此时本函数**不额外查库、不改 ctx** —— 关闭态与引入本 // 切片之前逐字同行为(复审要求自证的那一条)。 if pv, ok := s.voice.(personaVoice); ok && pv.PrimaryActive() { - ctx = voicetts.WithPersonaID(ctx, s.envResolver.sessionPersona(ctx, req.SessionID)) + ctx = voicetts.WithPersonaID(ctx, s.envResolver.effectivePersona(ctx, req.SessionID)) } audi, rung, err := ttsWithProvider(ctx, s.voice, req.Text) if err != nil { s.auditClaims(r, "audio.speech", "error") s.log.Warn("voice speech", "err", err) - writeError(w, apperr.Wrap(apperr.ErrInternal, "tts_failed", "speech synthesis failed")) + // 状态码统一 500(服务端配置缺失,不是客户端的错),但**错误码可区分**: + // "这个角色没有发音人"与"合成失败了"是两种运维动作(复审 r2 建议 3)。 + code, msg := speechErrorCode(err) + writeError(w, apperr.Wrap(apperr.ErrInternal, code, "%s", msg)) return } if len(audi.Data) == 0 { @@ -153,6 +160,25 @@ type voiceCloneRequest struct { Prefix string `json:"prefix,omitempty"` } +// speechErrorCode 把合成失败分类成**可区分**的错误码(状态码一律由调用方定 500)。 +// +// 两种"没有声音"必须能分开排查: +// - `tts_persona_unmapped`:这个角色在 `voice-tts.json` 的 `personas` 里没有映射 +// —— 运维要在面板里补一条(**绝不**用别的角色顶); +// - `tts_no_character`:连生效人格都解析不到、且没配 `default` —— 运维要配 default +// 或先选定人格; +// - `tts_failed`:真正的合成失败(上游非 200 / 非音频 / 超时等)。 +func speechErrorCode(err error) (code, msg string) { + switch { + case errors.Is(err, voicetts.ErrPersonaUnmapped): + return "tts_persona_unmapped", "this persona has no speech character configured" + case errors.Is(err, voicetts.ErrNoCharacter): + return "tts_no_character", "no speech character for this request" + default: + return "tts_failed", "speech synthesis failed" + } +} + // handleVoiceClone 经百炼声音注册 API(声音复刻 create_voice)从公开 // 参考音频 URL 创建自定义 TTS 音色,并返回新的 // voice_id。仅 owner + 带审计;参考 URL 绝不写入审计 diff --git a/internal/services/gateway/persona_env.go b/internal/services/gateway/persona_env.go index 485b728..77057d7 100644 --- a/internal/services/gateway/persona_env.go +++ b/internal/services/gateway/persona_env.go @@ -72,10 +72,20 @@ type turnEnvResolver struct { log *slog.Logger } +// defaultPersonaID 返回库中被标记为默认的人格 id(管理端记忆检索的回退用)。 +// 它转调解析器的同一实现,避免"两处各扫一遍 List()"而口径分叉。 +func (s *Server) defaultPersonaID() string { + return defaultPersonaIDFrom(s.personas) +} + // owner 返回 sessionID 一个面向 owner 回合的每回合环境: // 全访问 owner env(身份不变)加上该会话绑定的角色。 // 空白角色意味着「继承全局默认」;loop 执行那最后一次 -// 回退,因此网关永远不必知道默认角色的 id。 +// 回退(`Options.PersonaID`),因此 **loop 的输入**是绑定本身而不是生效人格。 +// +// 🔴 呈现层(TTS 音色)要的是**生效人格**,不是绑定 —— 主会话绑定恒为空,把它当成 +// 「没有角色」就会让主会话的声音不跟全局人格走。取生效人格请用 `effectivePersona` +// / `ownerWithPersona`(同一份数据、同一次会话读取)。 // // 构造走 loop.OwnerEnvFor —— 与 IM 的主人私聊车道**同一个入口** // (channelbridge/transcript.go 的 turnEnv):两条车道的 runEnv 因此逐字段同源, @@ -107,15 +117,66 @@ func (t turnEnvResolver) sessionPersona(ctx context.Context, sessionID string) s return personaID } -// defaultPersonaID 返回库中默认角色的人格 id,未接线人格 -// 存储或没有角色被标记为默认时返回 ""。它是 -// owner.yaml:defaultPersona 的网关侧视图,用于在没有会话时 -// 需要角色 id 的场合(管理记忆搜索回退,design/22 §4.5)。 -func (s *Server) defaultPersonaID() string { - if s.personas == nil { +// turnPersona 是"这一回合由哪个角色应答"的**单次解析**结果: +// +// - Binding 是会话的**显式**绑定(`chat_sessions.persona_id`;空 = 继承全局默认); +// - Effective 是**生效人格** —— 会话有绑定就取绑定,否则取全局默认人格。 +// +// 两者刻意分开:`loop.SessionEnv.PersonaID` 要的是**绑定**(空由 loop 自己回退到 +// `Options.PersonaID`,那是既有的唯一回退点,不许在这里替它决定);而**呈现层** +// (TTS 音色、前端头像/名字/心情)要的是**生效人格** —— 前端 `lib/persona.ts` 的 +// `bound ?? defaultPersona(personas)` 就是这个口径。 +// +// 🔴 为什么必须有 Effective:主会话(`web`)的 persona_id **恒为空且没有重绑路径** +// (`session_persona_test.go`),而"空"的真实语义是「继承全局默认人格」。若把空 +// 当成"没有生效人格",主会话换全局人格后声音就不会跟 —— 这正是主人抱怨的那件事, +// 而且发生在唯一能给普通聊天出声的路径上(复审 r2 P2-1)。 +type turnPersona struct { + // Binding 是会话显式绑定(可能为空)。 + Binding string + // Effective 是生效人格(会话绑定 → 全局默认人格 → 空)。 + Effective string +} + +// resolvePersona 一次解析出 Binding 与 Effective(**一次**会话读取,零重复 I/O)。 +func (t turnEnvResolver) resolvePersona(ctx context.Context, sessionID string) turnPersona { + binding := t.sessionPersona(ctx, sessionID) + if binding != "" { + return turnPersona{Binding: binding, Effective: binding} + } + return turnPersona{Effective: t.defaultPersonaID()} +} + +// effectivePersona 返回该会话的**生效人格**:有绑定取绑定,否则取全局默认人格。 +// 两者都没有时返回 ""(= 连默认人格都没配),由调用方按"没有角色"处置。 +func (t turnEnvResolver) effectivePersona(ctx context.Context, sessionID string) string { + return t.resolvePersona(ctx, sessionID).Effective +} + +// ownerWithPersona 与 owner 同义,但**顺带**返回这次解析出的 Binding/Effective —— +// 语音收尾需要生效人格选音色,而 owner 已经在读会话,因此这里复用同一次读取 +// (WS 语音回合不为选音色多查一次库)。 +func (t turnEnvResolver) ownerWithPersona(ctx context.Context, sessionID string) (loop.SessionEnv, turnPersona) { + tp := t.resolvePersona(ctx, sessionID) + return loop.OwnerEnvFor(tp.Binding), tp +} + +// defaultPersonaID 返回库中被标记为默认的人格 id,未接线人格存储或没有角色被标记为 +// 默认时返回 ""。它是 `owner.yaml:defaultPersona` 在网关侧的**唯一**视图 —— 与 +// `(*Server).defaultPersonaID` 同源(后者直接转调这里,避免两处各写一份扫描)。 +func (t turnEnvResolver) defaultPersonaID() string { + if t.personas == nil { return "" } - for _, c := range s.personas.List() { + return defaultPersonaIDFrom(t.personas) +} + +// defaultPersonaIDFrom 是上面那个口径的共享实现(`*Server` 与解析器共用)。 +func defaultPersonaIDFrom(personas PersonaStore) string { + if personas == nil { + return "" + } + for _, c := range personas.List() { if c.Default && c.PersonaID != "" { return c.PersonaID } diff --git a/internal/services/gateway/voice.go b/internal/services/gateway/voice.go index 6049e81..6aee011 100644 --- a/internal/services/gateway/voice.go +++ b/internal/services/gateway/voice.go @@ -174,30 +174,32 @@ func (h *hub) runVoiceTurn(c *wsConn, req voiceInputRequest) { } // Web 主会话是主人的私密对话,因此它绑定全访问 env:记忆读+写,行为不变。 // 角色来自该会话的绑定(design/22 §4.5)。 - env := h.envResolver.owner(ctx, req.SessionID) + // 语音收尾要用**生效人格**选音色(会话绑定 → 全局默认人格),而 env 只带绑定 + // (loop 自己回退)。这里**复用同一次会话读取**,不为选音色多查一次库。 + env, turnPersona := h.envResolver.ownerWithPersona(ctx, req.SessionID) msgs := []model.Message{model.TextMessage(model.RoleUser, transcript)} // 两条「怎么回到这条连接」的通路都由本连接给出:收尾回调(音频回复,C1)与 // 定向拒绝回调(队列满,G5)。编排器只把它们当成本回合的传输线索。 - ctx = withVoiceReplySink(ctx, h.voiceReplySink(c, req.SessionID, req.DeviceID, env.PersonaID, transcript)) + ctx = withVoiceReplySink(ctx, h.voiceReplySink(c, req.SessionID, req.DeviceID, turnPersona.Effective, transcript)) ctx = withPendingFullNotifier(ctx, c.notifyPendingFull) if h.orchestrator != nil { h.orchestrator.Submit(ctx, req.SessionID, msgs, env) return } - h.runVoiceTurnDirect(ctx, c, req.SessionID, req.DeviceID, transcript, env) + h.runVoiceTurnDirect(ctx, c, req.SessionID, req.DeviceID, turnPersona.Effective, transcript, env) } // runVoiceTurnDirect 是未接编排器时的旧直跑路径(未接线的构建 / 裸 hub 单元 // 测试):转写之后的回合直接跑 loop,然后立即合成并回音频。它与编排器路径共用 // 同一个帧构造(emitVoiceOutput),因此两条路径的 voice_output 契约一致。 -func (h *hub) runVoiceTurnDirect(ctx context.Context, c *wsConn, sessionID, deviceID, transcript string, env loop.SessionEnv) { +func (h *hub) runVoiceTurnDirect(ctx context.Context, c *wsConn, sessionID, deviceID, personaID, transcript string, env loop.SessionEnv) { res, err := h.runner.Run(loop.WithSessionEnv(ctx, env), sessionID, []model.Message{model.TextMessage(model.RoleUser, transcript)}) if err != nil { h.voiceLog().Warn("voice chat run", "err", err, "session", sessionID, "device_id", deviceID) h.voiceError(c, "voice chat failed") return } - h.synthesizeVoiceReply(ctx, c, sessionID, deviceID, env.PersonaID, transcript, voiceReplyText(res)) + h.synthesizeVoiceReply(ctx, c, sessionID, deviceID, personaID, transcript, voiceReplyText(res)) } // voiceReplySink 返回本次语音请求的收尾回调:回合结算后取助手正文 → TTS → diff --git a/internal/services/gateway/voice_persona_inherit_test.go b/internal/services/gateway/voice_persona_inherit_test.go new file mode 100644 index 0000000..8666727 --- /dev/null +++ b/internal/services/gateway/voice_persona_inherit_test.go @@ -0,0 +1,142 @@ +package gateway + +// 本文件是复审 r2 P2-1 的**回归锁**:主会话(`web`)的 `persona_id` 恒为空且没有 +// 重绑路径,而"空"的真实语义是「**继承全局默认人格**」(`loop/persona.go` 回退到 +// `Options.PersonaID`;前端 `lib/persona.ts` 的 `bound ?? defaultPersona(personas)`)。 +// +// 若把空当成"没有生效人格",主会话聊天是 furina、声音却是 `voice-tts.json` 的 +// `default` 那个人 —— 主人逐字抱怨的那件事,而且发生在唯一能给普通聊天出声的路径上。 +// +// 两条用例分别钉住 REST 与 WS 两条既有出站口;变异(把 effectivePersona 退回 +// 「空 = 无」)必须打红。 + +import ( + "context" + "encoding/base64" + "encoding/json" + "net/http" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" + personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" + voicetts "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/tts" +) + +// defaultPersonaRegistry 是一个"全局默认人格 = furina"的人格库。 +func defaultPersonaRegistry() PersonaStore { + return &stubPersonas{list: []personareg.Character{ + {ID: "Cyrene", Name: "Cyrene", PersonaID: "cyrene"}, + {ID: "芙宁娜", Name: "芙宁娜", PersonaID: "furina", Default: true}, + }} +} + +// unboundHistory 模拟**主会话**:SessionPersona 恒报空串(继承全局默认)。 +type unboundHistory struct{ *fakeHistory } + +func (h unboundHistory) SessionPersona(context.Context, string) (string, error) { return "", nil } + +// TestSpeechEndpointUnboundSessionInheritsGlobalPersona 是复审那条用例的复现: +// 主会话(未绑定)+ 全局默认人格 furina + `default` 配成 cyrene 的角色 ⇒ +// 必须用 **furina 的** 声音(不是 default)。 +func TestSpeechEndpointUnboundSessionInheritsGlobalPersona(t *testing.T) { + env := newEnv(t, nil, false) + rec := &vocalRecorder{} + // default 故意指向 cyrene 的角色:修好之前这里会被选中(复审报的红)。 + env.srv.SetVoice(emotionTTSService(t, rec, voicetts.Config{ + Personas: personaCharacters, + Default: personaCharacters["cyrene"], + }, &silentBase{})) + env.srv.envResolver = turnEnvResolver{ + history: unboundHistory{fakeHistory: &fakeHistory{}}, + personas: defaultPersonaRegistry(), + log: env.srv.log, + } + token := env.token(t, auth.RoleOwner) + + // 主会话就是 "web"(与 history.MainSessionID 同值;这里只声明"未绑定"这一事实)。 + resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/audio/speech", map[string]any{ + "text": "你好,我是苏梦。", "sessionId": "web", + }, token) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, body = %s", resp.StatusCode, body) + } + got := rec.Voices() + want := personaCharacters["furina"] + if len(got) != 1 || got[0] != want { + t.Fatalf("主会话(继承全局人格 furina)用了 %v 的声音,want [%s] —— 这正是主人抱怨的那件事", got, want) + } + if string(body) != string(fakeWAV(want)) { + t.Fatalf("响应体不是 furina 的声音: %q", string(body)) + } +} + +// TestVoiceOutputUnboundSessionInheritsGlobalPersona 是同一条口径在 WS 口上的复现: +// 帧里的音频必须来自**全局默认人格**的角色。 +func TestVoiceOutputUnboundSessionInheritsGlobalPersona(t *testing.T) { + hb := newTestHub(&stubRunner{res: &loop.Result{Text: "你好,我是苏梦。", Steps: 1, StopReason: "stop"}}, nil) + hb.envResolver = turnEnvResolver{ + history: unboundHistory{fakeHistory: &fakeHistory{}}, + personas: defaultPersonaRegistry(), + log: sourcedLog(nil), + } + rec := &vocalRecorder{} + hb.voice = emotionTTSService(t, rec, voicetts.Config{ + Personas: personaCharacters, + Default: personaCharacters["cyrene"], + }, &silentBase{}) + c := newOwnerConn(hb, 32) + hb.register(c) + + c.route(wsInbound{Type: "voice_input", Data: json.RawMessage(`{"text":"你好","format":"wav"}`)}) + ev := readFakeFrame(t, c) + if ev.Type != "voice_output" { + t.Fatalf("frame = %q, want voice_output (%+v)", ev.Type, ev) + } + data, ok := ev.Data.(map[string]any) + if !ok { + t.Fatalf("frame data = %T", ev.Data) + } + raw, err := base64.StdEncoding.DecodeString(asString(data["audio"])) + if err != nil || len(raw) == 0 { + t.Fatalf("帧里没有可解码的音频(err=%v)", err) + } + want := personaCharacters["furina"] + if string(raw) != string(fakeWAV(want)) { + t.Fatalf("主会话的帧里不是 furina 的声音: %q", string(raw)) + } + if got := rec.Voices(); len(got) != 1 || got[0] != want { + t.Fatalf("请求体里的 voice = %v, want [%s]", got, want) + } +} + +// TestSpeechEndpointPersonaUnmappedErrorCode 钉住复审 r2 建议 3:状态码仍是 500 +// (服务端配置缺失,不是客户端的错),但**错误码可区分** —— 不许跟一般合成失败混成一个码。 +func TestSpeechEndpointPersonaUnmappedErrorCode(t *testing.T) { + env := newEnv(t, nil, false) + env.srv.SetVoice(emotionTTSService(t, &vocalRecorder{}, voicetts.Config{ + Personas: personaCharacters, + }, &silentBase{})) + env.srv.envResolver = turnEnvResolver{ + history: defaultPersonaRegistryHistory(), + personas: defaultPersonaRegistry(), + log: env.srv.log, + } + token := env.token(t, auth.RoleOwner) + + resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/audio/speech", map[string]any{ + "text": "你好", "sessionId": "web:sub-7", + }, token) + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500(服务端配置缺失)", resp.StatusCode) + } + code := errorCodeOf(t, body) + if code != "tts_persona_unmapped" { + t.Fatalf("error.code = %q, want tts_persona_unmapped(body=%s)", code, body) + } +} + +// defaultPersonaRegistryHistory 是一个"会话已绑定到没有 TTS 映射的人格"的存储。 +func defaultPersonaRegistryHistory() HistoryStore { + return personaHistory{fakeHistory: &fakeHistory{}, persona: "stranger"} +} From 1c2944fcf4948bfbdba4bd981417c82c99ed4fdb Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 26 Oct 2026 13:32:04 +0800 Subject: [PATCH 5/6] =?UTF-8?q?docs(voice):=20=E6=94=B6=E5=9B=9E=20r2=20?= =?UTF-8?q?=E4=BF=AE=E6=8E=89=E7=9A=84=E6=97=A7=E8=AF=AD=E4=B9=89=E6=B3=A8?= =?UTF-8?q?=E9=87=8A=20+=20=E8=AE=A9=E5=85=B3=E9=97=AD=E6=80=81=E5=8F=98?= =?UTF-8?q?=E5=BC=82=E7=9C=9F=E8=83=BD=E6=89=93=E7=BA=A2=EF=BC=88r3=20P2-1?= =?UTF-8?q?/P2-2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2-1(两处注释在描述已被推翻的旧语义,会把 r2 的修复写回去): - voice.go 的 voiceReplySink 文档:原写「personaID = env.PersonaID,会话无绑定时为空」 ⇒ 实际传的是 turnPersona.Effective,**未绑定 ≠ 空**(有全局默认人格时非空)。 改为写明「生效人格(会话绑定 → 全局默认人格;两者都没有才是空)」,并显式指出 它不是 env.PersonaID、把空当"没有角色"会让主会话落到 default(正是 r2 修掉的)。 - voice.go 的 synthesizeVoiceReply:原写「空 personaID = 会话无绑定 ⇒ 走 default」 ⇒ 反了。改为「空 = 连全局默认人格都没有 ⇒ 才走 voice-tts.json 的 default」。 - 同族 format 残留措辞:client_test.go 的「非法格式的回落」(该行为已不存在)、 cmd/cyrene/wire_voice_tts_test.go 的「角色/格式/超时按文件来」都改写。 - nit:voice.go 的 `env, turnPersona := ...` 遮蔽同包类型名,改成 `tp`。 P2-2(我上轮那条「摘掉 PrimaryActive 闸 ⇒ 用例红」不成立,复审实测仍绿): 根因是 audioStubVoice 不实现 PrimaryActive,拦住 persona 读取的只有类型断言。 按塔选的第二种收口(把测试做强): - audioStubVoice 补 `PrimaryActive() bool { return true }`(它是"生效中的 persona voice"替身),并在注释里说明它存在的理由; - 关闭态用例改用新替身 inactivePersonaVoice(实现 personaVoice 但报 false), 于是 PrimaryActive 成为关闭态唯一的闸; - 重跑变异(摘掉 `&& pv.PrimaryActive()`)**实测打红**: 「未生效的 personaVoice 却查了 1 次会话人格(关闭态行为变了)」。 未动:功能面、错误码、500 状态码、白名单、session 可读、隐私/format/Stats/redactURL。 go build ./... + go vet ./... + go test -count=1 -race 覆盖 tts/runtimeconfig/gateway/ cmd/cyrene 全绿。 --- cmd/cyrene/wire_voice_tts_test.go | 3 ++- internal/runtime/voice/tts/client_test.go | 3 ++- internal/services/gateway/audio_test.go | 10 ++++++++ internal/services/gateway/voice.go | 22 ++++++++++------ .../services/gateway/voice_emotiontts_test.go | 25 ++++++++++++++----- 5 files changed, 47 insertions(+), 16 deletions(-) diff --git a/cmd/cyrene/wire_voice_tts_test.go b/cmd/cyrene/wire_voice_tts_test.go index c1d04d8..79ab913 100644 --- a/cmd/cyrene/wire_voice_tts_test.go +++ b/cmd/cyrene/wire_voice_tts_test.go @@ -3,7 +3,8 @@ package main // 本文件锁定切片 M35 在**组合根**上的三条口径(都是"改坏就静默不工作/静默开错"的那种): // // 1. 面板文件缺失 ⇒ **这一级不启用**,返回的仍是既有链本身(行为逐字不变); -// 2. 文件里 `enabled:true` ⇒ 返回 emotionTTS 门面,角色/格式/超时按文件来; +// 2. 文件里 `enabled:true` ⇒ 返回 emotionTTS 门面,`personas`/`default`/`timeoutSec` +// 按文件来(没有"角色"或"格式"这种全局键:发音人只能来自 persona 映射); // 3. 文件坏掉 ⇒ **不启用**(宁可没有这一级,也不拿读不懂的配置去驱动外部服务)。 // // 它用 `DefaultVoiceTTSPath` 的真实落点($DATA_DIR/config/voice-tts.json)跑, diff --git a/internal/runtime/voice/tts/client_test.go b/internal/runtime/voice/tts/client_test.go index f577208..1582748 100644 --- a/internal/runtime/voice/tts/client_test.go +++ b/internal/runtime/voice/tts/client_test.go @@ -212,7 +212,8 @@ func TestSpeechGuards(t *testing.T) { } } -// TestConfigNormalized 钉住默认值与非法格式的回落(不让一个写错的格式关掉语音面)。 +// TestConfigNormalized 钉住默认值的补齐与空白/键的归一化(**没有**格式项了:那个 +// 假开关已删除,请求体里的 `response_format` 恒为 wav,见 requestFormat)。 // // 🔴 它同时钉住一条**反向**口径:归一化**不得**给任何角色兜底 —— 代码里写死一个 // 默认发音人,正是主人点名的病害(换人格仍用老声音)。 diff --git a/internal/services/gateway/audio_test.go b/internal/services/gateway/audio_test.go index 44f01ab..2bae8ca 100644 --- a/internal/services/gateway/audio_test.go +++ b/internal/services/gateway/audio_test.go @@ -51,6 +51,16 @@ func (s *audioStubVoice) CloneVoice(ctx context.Context, referenceURL, prefix st return s.cloneVoiceID, s.cloneErr } +// PrimaryActive 让本替身**实现**网关的 `personaVoice` 扩展并报告"按 persona 选发音 +// 的那一级正在生效"(复审 r3 P2-2)。 +// +// 🔴 它存在是为了让"关闭态不查库"那条用例的变异**真能打红**:此前 `audioStubVoice` +// 不实现 `PrimaryActive`,于是拦下 persona 读取的只有 `s.voice.(personaVoice)` 这个 +// **类型断言**,把 `&& pv.PrimaryActive()` 摘掉用例照样绿 —— 等于那条变异没被覆盖。 +// 现在类型断言会通过,拦住它的只剩这个布尔值。要表达"未生效"的替身请用 +// `inactivePersonaVoice`(`voice_emotiontts_test.go`)。 +func (s *audioStubVoice) PrimaryActive() bool { return true } + func TestHandleTranscribe(t *testing.T) { env := newEnv(t, nil, false) sv := &audioStubVoice{sttText: "你好世界"} diff --git a/internal/services/gateway/voice.go b/internal/services/gateway/voice.go index 6aee011..c05d0ac 100644 --- a/internal/services/gateway/voice.go +++ b/internal/services/gateway/voice.go @@ -176,17 +176,17 @@ func (h *hub) runVoiceTurn(c *wsConn, req voiceInputRequest) { // 角色来自该会话的绑定(design/22 §4.5)。 // 语音收尾要用**生效人格**选音色(会话绑定 → 全局默认人格),而 env 只带绑定 // (loop 自己回退)。这里**复用同一次会话读取**,不为选音色多查一次库。 - env, turnPersona := h.envResolver.ownerWithPersona(ctx, req.SessionID) + env, tp := h.envResolver.ownerWithPersona(ctx, req.SessionID) msgs := []model.Message{model.TextMessage(model.RoleUser, transcript)} // 两条「怎么回到这条连接」的通路都由本连接给出:收尾回调(音频回复,C1)与 // 定向拒绝回调(队列满,G5)。编排器只把它们当成本回合的传输线索。 - ctx = withVoiceReplySink(ctx, h.voiceReplySink(c, req.SessionID, req.DeviceID, turnPersona.Effective, transcript)) + ctx = withVoiceReplySink(ctx, h.voiceReplySink(c, req.SessionID, req.DeviceID, tp.Effective, transcript)) ctx = withPendingFullNotifier(ctx, c.notifyPendingFull) if h.orchestrator != nil { h.orchestrator.Submit(ctx, req.SessionID, msgs, env) return } - h.runVoiceTurnDirect(ctx, c, req.SessionID, req.DeviceID, turnPersona.Effective, transcript, env) + h.runVoiceTurnDirect(ctx, c, req.SessionID, req.DeviceID, tp.Effective, transcript, env) } // runVoiceTurnDirect 是未接编排器时的旧直跑路径(未接线的构建 / 裸 hub 单元 @@ -206,8 +206,13 @@ func (h *hub) runVoiceTurnDirect(ctx context.Context, c *wsConn, sessionID, devi // **定向**回发起它的那根连接。它是 C1 的核心接线点 —— 回合异步完成,只有结算处 // 知道助手正文。 // -// personaID 是**本回合**解析出的角色(`env.PersonaID`,会话无绑定时为空)。它随 -// 回调一起传下去,使合成按这一回合的角色选发音人 —— 而不是进程启动时定死一个。 +// personaID 是**本回合的生效人格**(会话绑定 → 全局默认人格;两者都没有才是空, +// 见 `turnEnvResolver.resolvePersona`)。🔴 它**不是** `env.PersonaID` +// (那是"绑定本身",空表示继承,由 loop 自己回退)—— 未绑定 ≠ 空:主会话永远未绑定, +// 但只要有全局默认人格,这里就非空。把空当成"没有角色"会让主会话的声音落到 +// `voice-tts.json` 的 `default`(复审 r2 P2-1 修掉的正是这个)。 +// +// 它随回调一起传下去,使合成按这一回合的角色选发音人 —— 而不是进程启动时定死一个。 // 它不是身份输入(身份仍由 actor 元数据决定),只决定用哪个上游音色。 // // 连接已断(客户端走了)时记一条带来源的 WARN,绝不静默丢:那正是「静默成功但 @@ -230,7 +235,7 @@ func (h *hub) voiceReplySink(c *wsConn, sessionID, deviceID, personaID, transcri // 能提示「没生成声音」(绝不是只回一条错误帧就完事); // - 没有助手正文(被取消 / 空回复)时不打一次无谓的 TTS,直接回降级帧 —— // 客户端总能看到这条语音回合的终点,而不是永远等一个不会来的 voice_output; -// - **发音人按本回合的 personaID 现解析**(`voicetts.WithPersonaID`):换人格后 +// - **发音人按本回合的生效人格现解析**(`voicetts.WithPersonaID`):换人格后 // 绝不会继续用老角色的声音(主人 2026-10-26 的硬要求); // - 日志只带会话与设备 id,**绝不记正文/音频**。 func (h *hub) synthesizeVoiceReply(ctx context.Context, c *wsConn, sessionID, deviceID, personaID, transcript, reply string) { @@ -239,8 +244,9 @@ func (h *hub) synthesizeVoiceReply(ctx context.Context, c *wsConn, sessionID, de emitVoiceOutput(c, sessionID, deviceID, transcript, "", voice.Audio{}, providerSignal("", true)) return } - // 把**本回合**的角色交给语音面选发音人;空 personaID = 会话无绑定 ⇒ 语音面 - // 走它的 default 分支(没配 default 就不合成,文本已发出)。 + // 把**本回合的生效人格**交给语音面选发音人。空 = **连全局默认人格都没有** + // (不是"会话无绑定"——未绑定但存在全局默认人格时这里非空)⇒ 语音面才走 + // `voice-tts.json` 的 `default`(没配则不合成,文本已发出)。 ctx = voicetts.WithPersonaID(ctx, personaID) audi, rung, err := ttsWithProvider(ctx, h.voice, reply) if err != nil { diff --git a/internal/services/gateway/voice_emotiontts_test.go b/internal/services/gateway/voice_emotiontts_test.go index aac1329..257eeff 100644 --- a/internal/services/gateway/voice_emotiontts_test.go +++ b/internal/services/gateway/voice_emotiontts_test.go @@ -232,14 +232,19 @@ func TestVoiceOutputFrameVoiceFollowsTurnPersona(t *testing.T) { // TestSpeechEndpointDisabledVoiceDoesNotReadPersona 是**关闭态零行为变化**的自证 // (复审要求在当前 tip 上重跑的那一条,而我这一版动了 `gateway/audio.go`): // -// 注入的语音服务不是"按 persona 选发音"的那一类(既有链 / stub)时,语音合成 -// **一次都不查会话人格** —— 与引入本切片之前逐字同行为;反之(真 emotionTTS 门面) -// 才会查,说明这个闸不是"永远不查"。 +// 注入的语音服务**实现了 personaVoice 却报告未生效**(`inactivePersonaVoice`, +// 模拟"门面在、这一级关着")时,语音合成**一次都不查会话人格** —— 与引入本切片 +// 之前逐字同行为;反之(真 emotionTTS 门面,PrimaryActive=true)才会查,说明这个闸 +// 不是"永远不查"。 // -// 变异:去掉 handleSpeech 里的 `pv.PrimaryActive()` 闸 ⇒ 本用例第一段红。 +// 变异:去掉 handleSpeech 里的 `&& pv.PrimaryActive()`(只留类型断言)⇒ 本用例 +// 第一段红。🔴 这条变异此前**打不红**,因为替身不实现 `PrimaryActive`,拦住读库的 +// 只有类型断言(复审 r3 P2-2);现在替身实现了它,布尔值才是唯一的闸。 func TestSpeechEndpointDisabledVoiceDoesNotReadPersona(t *testing.T) { env := newEnv(t, nil, false) - stub := &audioStubVoice{ttsAudio: mvoice.Audio{Format: "wav", Data: fakeWAV("chain")}} + stub := &inactivePersonaVoice{audioStubVoice: audioStubVoice{ + ttsAudio: mvoice.Audio{Format: "wav", Data: fakeWAV("chain")}, + }} env.srv.SetVoice(stub) counter := &countingHistory{fakeHistory: &fakeHistory{}} env.srv.envResolver = turnEnvResolver{history: counter, log: env.srv.log} @@ -252,7 +257,7 @@ func TestSpeechEndpointDisabledVoiceDoesNotReadPersona(t *testing.T) { t.Fatalf("status = %d, body = %s", resp.StatusCode, body) } if got := counter.Calls(); got != 0 { - t.Fatalf("非 persona 语音服务却查了 %d 次会话人格(关闭态行为变了)", got) + t.Fatalf("未生效的 personaVoice 却查了 %d 次会话人格(关闭态行为变了)", got) } if string(body) != string(fakeWAV("chain")) { t.Fatalf("关闭态的音频不是既有链产物: %q", string(body)) @@ -295,6 +300,14 @@ func (h *countingHistory) Calls() int { return h.calls } +// inactivePersonaVoice 是"**实现了** personaVoice 但报告这一级未生效"的替身 +// (模拟 emotionTTS 门面存在、`enabled` 关着的情形)。它让 `PrimaryActive()` 成为 +// 关闭态唯一的闸 —— 这样把 `&& pv.PrimaryActive()` 摘掉的变异才会打红。 +type inactivePersonaVoice struct{ audioStubVoice } + +// PrimaryActive 报告"按 persona 选发音"的那一级**未**生效。 +func (i *inactivePersonaVoice) PrimaryActive() bool { return false } + // asString 安全地取一个 string 值(帧载荷是 map[string]any)。 func asString(v any) string { s, _ := v.(string) From b90811fae4702ee5e70f1a933d457176fe204056 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 26 Oct 2026 13:39:35 +0800 Subject: [PATCH 6/6] =?UTF-8?q?docs(voice):=20=E6=94=B6=E5=8F=A3=E3=80=8C?= =?UTF-8?q?=E6=B2=A1=E4=BC=9A=E8=AF=9D/=E6=B2=A1=E7=BB=91=E5=AE=9A?= =?UTF-8?q?=E5=B0=B1=E8=B5=B0=20default=E3=80=8D=E7=9A=84=E9=95=9C?= =?UTF-8?q?=E5=83=8F=E6=B3=A8=E9=87=8A=EF=BC=88r4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 唯一 finding:audio.go 的 speechRequest.SessionID 注释原本写「省略它时本口没有会话 概念 ⇒ 走 emotionTTS 的 default 分支」——**反了**。真实链路是 effectivePersona(ctx,"") → sessionPersona("") 为空**不是终点** → Effective = defaultPersonaID() → 常规安装必有全局默认人格 ⇒ personaId 非空 ⇒ 走 personas 映射。 照那句话改的人会写出 `if req.SessionID == "" { personaID = "" }`,正是 r2 修掉的病。 改为:省略时按**全局默认人格**选发音人(会话绑定 → 全局默认人格 → 两者都没有才落到 default 字符),并显式写了"不要写成 if SessionID == \"\" { personaID = \"\" }"。 顺手同根三处(不留第四轮): - tts/config.go 的 Config.Default 注释:成因表「会话无绑定 / 读取失败 / 该出口本身 没有会话」已过时 ⇒ 改为「空串的唯一合法成因是连全局默认人格都没有」,并写明不是 "会话未绑定"、也不是"出站口没有会话"。 - runtimeconfig/voicetts.go 的 Default 字段注释与文件头同族措辞同步; - selection.go 的 WithPersonaID / Resolve 分支表、service.go 的分支小标把 「解析不到」统一改成「连全局默认人格都没有」; - voice_emotiontts_test.go 的 TestSpeechEndpointNoSessionUsesDefault 注释同步(该用例 没接人格库,所以确实落到 default —— 但成因写对了); - wire_voice_tts_test.go nit:改成「已废弃的全局 `character` 不生效」(墓碑键仍在)。 纯注释:`git diff` 里非注释改动行为空。go build/vet 通过;四包 go test -count=1 全绿。 --- cmd/cyrene/wire_voice_tts_test.go | 2 +- internal/config/runtimeconfig/voicetts.go | 13 +++++++++---- internal/runtime/voice/tts/config.go | 13 +++++++++---- internal/runtime/voice/tts/selection.go | 13 +++++++------ internal/runtime/voice/tts/service.go | 2 +- internal/runtime/voice/tts/service_test.go | 2 +- internal/services/gateway/audio.go | 15 +++++++++++---- .../services/gateway/voice_emotiontts_test.go | 3 ++- 8 files changed, 41 insertions(+), 22 deletions(-) diff --git a/cmd/cyrene/wire_voice_tts_test.go b/cmd/cyrene/wire_voice_tts_test.go index 79ab913..c6ad112 100644 --- a/cmd/cyrene/wire_voice_tts_test.go +++ b/cmd/cyrene/wire_voice_tts_test.go @@ -4,7 +4,7 @@ package main // // 1. 面板文件缺失 ⇒ **这一级不启用**,返回的仍是既有链本身(行为逐字不变); // 2. 文件里 `enabled:true` ⇒ 返回 emotionTTS 门面,`personas`/`default`/`timeoutSec` -// 按文件来(没有"角色"或"格式"这种全局键:发音人只能来自 persona 映射); +// 按文件来(发音人只能来自 persona 映射;已废弃的全局 `character` 不生效); // 3. 文件坏掉 ⇒ **不启用**(宁可没有这一级,也不拿读不懂的配置去驱动外部服务)。 // // 它用 `DefaultVoiceTTSPath` 的真实落点($DATA_DIR/config/voice-tts.json)跑, diff --git a/internal/config/runtimeconfig/voicetts.go b/internal/config/runtimeconfig/voicetts.go index 1f61e6a..85be5ff 100644 --- a/internal/config/runtimeconfig/voicetts.go +++ b/internal/config/runtimeconfig/voicetts.go @@ -10,7 +10,8 @@ // 🔴 **发音人必须绑定人格**(主人 2026-10-26 逐字:「TTS 的人物选择是要绑定后端的 // 角色的,不然会出现以后换了别的角色(人格配置),还是用老角色的 TTS 角色」): // 因此本文件的核心是 `personas`(personaId → characterId)与 `default`(**仅** -// 服务"解析不到 personaId")。全局 `character` 键已废弃并被忽略(见字段注释)。 +// 服务"连全局默认人格都没有"这一种情形 —— 不是"会话未绑定")。 +// 全局 `character` 键已废弃并被忽略(见字段注释)。 // 映射的**唯一真源**就是本文件 —— `configs/personas/*.yaml` 不参与。 // // 🔴 **刻意没有 speed / emotion / tone 三键**(塔 2026-10-26 裁决,实测证据): @@ -81,10 +82,14 @@ type VoiceTTSTuning struct { // - 有键但值为空串 = 该角色明确没有发音人 ⇒ 不合成(绝不借用别的角色)。 // - **没有这个键** ⇒ 同样不合成,且**不**回落到下面的 `default`。 Personas map[string]string `json:"personas,omitempty"` - // Default 是**仅当 personaId 解析不到**(会话无绑定 / 读取失败 / 该出站口本身 - // 没有会话)时使用的发音人;空 = 没配 ⇒ 那种情况**不合成**。 + // Default 是**仅当连生效人格都没有**(personaId 传空串)时使用的发音人; + // 空 = 没配 ⇒ 那种情况**不合成**。 // - // 🔴 它**不服务**「配了 personas 但没有这个 persona 的键」——那种情况一律不合成 + // 🔴 空串的唯一合法成因是"连全局默认人格都没有"(人格库未接线 / 没有任何角色被 + // 标为默认)—— **不是**"会话未绑定",也不是"这个出站口没有会话":那些情况下 + // 调用方传的是**生效人格**(会话绑定 → 全局默认人格),非空。 + // + // 它**不服务**「配了 personas 但没有这个 persona 的键」——那种情况一律不合成 // (见 `tts.Config.Resolve` 的拒绝分支)。用 default 去顶正是"换了人格还用老角色 // 声音"的病害本身。 Default string `json:"default,omitempty"` diff --git a/internal/runtime/voice/tts/config.go b/internal/runtime/voice/tts/config.go index bf95abb..f956a2f 100644 --- a/internal/runtime/voice/tts/config.go +++ b/internal/runtime/voice/tts/config.go @@ -92,9 +92,15 @@ type Config struct { // `char_` 前缀或不带前缀的写法,见 Resolve)。它有键但值为空 = 该角色 // 没有可用发音人 ⇒ 不合成(**绝不**借用别的角色的声音)。 Personas map[string]string - // Default 是**仅当 personaId 解析不到**(空串:会话无绑定 / 读取失败 / - // 该出口本身没有会话)时使用的发音人。它**不**服务"配了 Personas 但没有 - // 这个 persona 的键"那种情况 —— 那是 Resolve 的拒绝分支(主人点名的病害)。 + // Default 是**仅当连生效人格都没有**(personaId 传空串)时使用的发音人。 + // + // 🔴 空串的**唯一合法成因**是"连全局默认人格都没有"(人格库未接线 / 没有任何角色 + // 被标为默认)。**不是**"会话未绑定"、也不是"这个出站口没有会话":那些情况下 + // 调用方给的是**生效人格**(会话绑定 → 全局默认人格),非空。若把"没绑定"当成 + // 空传进来,`default` 就会顶掉全局默认人格的声音 —— 主人点名的病害(复审 r2/r4)。 + // + // 它**不**服务"配了 Personas 但没有这个 persona 的键"那种情况 —— 那是 Resolve + // 的拒绝分支。 Default string // Timeout 是单次合成超时;<=0 = DefaultTimeout。 Timeout time.Duration @@ -136,4 +142,3 @@ func normalizePersonas(in map[string]string) map[string]string { } return out } - diff --git a/internal/runtime/voice/tts/selection.go b/internal/runtime/voice/tts/selection.go index 205d8bc..638fc33 100644 --- a/internal/runtime/voice/tts/selection.go +++ b/internal/runtime/voice/tts/selection.go @@ -61,10 +61,10 @@ var ( // // 四条分支(与主人的验收清单逐条对应): // -// personaID 有映射 ⇒ (Selection{mapped}, nil) -// personaID 为空/解析不到 + 配了 Default ⇒ (Selection{default}, nil) -// personaID 为空/解析不到 + 没配 Default ⇒ ("", ErrNoCharacter) -// personaID 非空 + 没有映射(含映射值为空) ⇒ ("", ErrPersonaUnmapped) +// personaID 有映射 ⇒ (Selection{mapped}, nil) +// personaID 为空(连全局默认人格都没有)+ 配了 Default ⇒ (Selection{default}, nil) +// personaID 为空 + 没配 Default ⇒ ("", ErrNoCharacter) +// personaID 非空 + 没有映射(含映射值为空) ⇒ ("", ErrPersonaUnmapped) // // 🔴 第四行**不会**看 Default —— 这正是"不许借用另一个角色"的编码位置。 func (c Config) Resolve(personaID string) (Selection, error) { @@ -85,8 +85,9 @@ func (c Config) Resolve(personaID string) (Selection, error) { // personaKey 是 ctx 里携带 personaId 的键(每次请求现传,绝不缓存成全局)。 type personaKey struct{} -// WithPersonaID 返回携带本次请求所属角色 id 的 ctx。空串是合法的(= 解析不到), -// 与"没设置"同义 —— 两者都走 default 分支。 +// WithPersonaID 返回携带本次请求**生效人格** id 的 ctx。空串是合法的,但它的唯一 +// 合法成因是"连全局默认人格都没有"——**不是**"会话未绑定"(未绑定但存在全局默认人格 +// 时,调用方应传全局默认人格)。传空串只在 Resolve 层意味着"走 default 分支"。 // // 调用方是**知道会话**的那一层(网关的两条出站口):它先按当次请求解析出 // `chat_sessions.persona_id`,再交给语音面。语音面自己不去查库,因此这里没有 diff --git a/internal/runtime/voice/tts/service.go b/internal/runtime/voice/tts/service.go index 4d4aacc..fd93d1e 100644 --- a/internal/runtime/voice/tts/service.go +++ b/internal/runtime/voice/tts/service.go @@ -134,7 +134,7 @@ func (s *Service) TTS(ctx context.Context, text string) (mvoice.Audio, error) { // // 分支(与 `Config.Resolve` 一一对应,验收清单见 selection.go): // - 选到发音人 ⇒ 用它合成;上游失败 ⇒ 回落既有链 + WARN(口径不变); -// - **选不到发音人**(persona 无映射 / 解析不到且没配 default)⇒ **不合成、 +// - **选不到发音人**(persona 无映射 / 连生效人格都没有且没配 default)⇒ **不合成、 // 也不回落既有链**,直接回一个可分类错误 + 一条 WARN。回落会让她用**另一个 // 声音**说话,正是主人点名的病害;文本早已发出,因此这里"没有声音"是安全结局。 func (s *Service) TTSWithProvider(ctx context.Context, text string) (mvoice.Audio, string, error) { diff --git a/internal/runtime/voice/tts/service_test.go b/internal/runtime/voice/tts/service_test.go index f341eee..1ba24f2 100644 --- a/internal/runtime/voice/tts/service_test.go +++ b/internal/runtime/voice/tts/service_test.go @@ -9,7 +9,7 @@ package tts // 2. 🔴 同一进程先后用两个 persona,第二次绝不沿用第一次的 voice(回归锁, // 直接对着主人担心的事); // 3. persona 无映射且没配 default ⇒ **不合成**,且**上游请求数为 0**; -// 4. persona 解析不到且配了 default ⇒ 用 default; +// 4. 连生效人格都没有(传空串)且配了 default ⇒ 用 default; // 5. 有映射但上游拒绝 ⇒ 沿用既有降级(回落既有链,口径不变); // 6. 未启用 ⇒ 完全透传既有链;STT/克隆永远走既有链。 diff --git a/internal/services/gateway/audio.go b/internal/services/gateway/audio.go index a22227b..283b12a 100644 --- a/internal/services/gateway/audio.go +++ b/internal/services/gateway/audio.go @@ -30,10 +30,17 @@ type transcribeRequest struct { // 每请求情绪/语气覆盖,用于克隆音色(声音复刻);系统音色 // (longxiaochun_v3)忽略它,绝不接收自由形式的指令。 // -// SessionID 是**可选**的会话标识(切片 M35,纯增量):发音人按 -// 「该会话绑定的人格」解析(`chat_sessions.persona_id`)。省略它时本口没有会话 -// 概念 ⇒ 走 emotionTTS 的 default 分支(没配 default 就不合成)。客户端**绝不** -// 通过它选音色:音色由服务端的 persona 映射决定,请求只声明"这是哪个会话"。 +// SessionID 是**可选**的会话标识(切片 M35,纯增量):发音人按该会话的**生效 +// 人格**解析 —— 会话绑定 → **全局默认人格** → 两者都没有才是空。 +// +// 🔴 省略它**不等于**"没有角色":本口此时按**全局默认人格**选发音人(常规安装必 +// 有全局默认人格 ⇒ 仍走 `personas` 映射);只有连全局默认人格都没有时,才落到 +// `voice-tts.json` 的 `default` 字符(没配则不合成)。因此**不要**写成 +// `if req.SessionID == "" { personaID = "" }` —— 那会让本口永远用 `default` 那个 +// 声音,正是主人在意的「换人格还用老角色」(复审 r2/r4)。 +// +// 客户端**绝不**通过它选音色:音色由服务端的 persona 映射决定,请求只声明 +// "这是哪个会话"。 type speechRequest struct { Text string `json:"text"` Voice string `json:"voice,omitempty"` diff --git a/internal/services/gateway/voice_emotiontts_test.go b/internal/services/gateway/voice_emotiontts_test.go index 257eeff..2ea7b14 100644 --- a/internal/services/gateway/voice_emotiontts_test.go +++ b/internal/services/gateway/voice_emotiontts_test.go @@ -168,7 +168,8 @@ func TestSpeechEndpointUnmappedPersonaRefuses(t *testing.T) { } // TestSpeechEndpointNoSessionUsesDefault 钉住"这条口没有会话时"的诚实处置: -// 省略 sessionId ⇒ persona 解析不到 ⇒ 用配置的 default(没配则同样不合成)。 +// 省略 sessionId ⇒ 生效人格只能来自**全局默认人格**;本用例没有接人格库, +// 因此连默认人格都没有 ⇒ 才落到配置的 default(没配则同样不合成)。 func TestSpeechEndpointNoSessionUsesDefault(t *testing.T) { env := newEnv(t, nil, false) rec := &vocalRecorder{}