Files
Cyrene_v2/internal/runtime/voice/tts/config.go
T
AskaEth efc25c6a7f feat(voice): emotionTTS 发音人绑定会话人格(不再全局定死一个 character)
主人 2026-10-26 逐字:「TTS 的人物选择是要绑定后端的角色的,不然会出现以后换了
别的角色(人格配置),还是用老角色的 TTS 角色」——上一版把 character 写在面板文件
里当全局值,换人格后声音不变,是真缺陷。

配置形状(单一真源 = $DATA_DIR/config/voice-tts.json):
  {"enabled":true,
   "personas":{"<personaId>":"<characterId>"},
   "default":"<characterId>",   // 只服务「解析不到 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。
2026-10-26 12:57:35 +08:00

149 lines
7.2 KiB
Go
Raw 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.
// 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 只是**示例/文档用**的角色 id(主人指定的「Cyrene」,
// `GET /api/characters` 实测 id = `char_84fb88a3`)。
//
// 🔴 它**不是**代码里的兜底发音人:把任何角色 id 写死在代码里,正是主人
// 2026-10-26 点出的病害(换了人格仍用老角色的声音)。发音人一律由
// `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 = 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 三项,理由见包注释(上游硬编码或自推)。
// 🔴 也**没有全局 character**:发音人只能由 `Personas`(按会话角色映射)或
// `Default`(仅服务"解析不到 personaId")给出 —— 主人 2026-10-26 的硬要求
// 「TTS 的人物选择要绑定后端的角色,不然换了人格还是用老角色的声音」。
type Config struct {
// Enabled 是总开关。false = 该级整体不参与(调用方应保留既有链,
// 而不是把"没启用"当成"合成失败")。
Enabled bool
// BaseURL 是服务地址;空 = DefaultBaseURL。约定只填回环地址。
BaseURL 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
// 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(**不让它停摆** —— 一个写错的格式不该关掉整个语音面)。
//
// 它**不**给 `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.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
}
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
}