Files
AskaEth b90811fae4 docs(voice): 收口「没会话/没绑定就走 default」的镜像注释(r4)
唯一 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 全绿。
2026-10-26 13:40:00 +08:00

150 lines
8.6 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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`
// 同一家族口径。
//
// 🔴 **发音人必须绑定人格**(主人 2026-10-26 逐字:「TTS 的人物选择是要绑定后端的
// 角色的,不然会出现以后换了别的角色(人格配置),还是用老角色的 TTS 角色」):
// 因此本文件的核心是 `personas`(personaId → characterId)与 `default`(**仅**
// 服务"连全局默认人格都没有"这一种情形 —— 不是"会话未绑定")。
// 全局 `character` 键已废弃并被忽略(见字段注释)。
// 映射的**唯一真源**就是本文件 —— `configs/personas/*.yaml` 不参与。
//
// 🔴 **刻意没有 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"`
// 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"`
// TimeoutSec 是单次合成超时(**秒**)。零/负 = 没配 ⇒ 代码默认 60 秒。
//
// 用秒而不是 Go 时长字面量:这个文件是给人改的,写 `60` 比要求记住 `"60s"`
// 更不容易写错,也与既有 `$DATA_DIR/config/**` 的 `*_SEC` 口径一致。
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 }
// 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)
}
// 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
}