主人 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。
95 lines
4.6 KiB
Go
95 lines
4.6 KiB
Go
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(),
|
||
"personas", len(cfg.Personas),
|
||
"default_set", cfg.Default != "",
|
||
"format", cfg.Format,
|
||
"timeout_sec", int(cfg.Timeout/time.Second),
|
||
"config", runtimeconfig.VoiceTTSConfigRelPath)
|
||
return service
|
||
}
|
||
|
||
// voiceTTSConfig 读面板配置并映射成 provider 配置。第二个返回值报告"这一级是否
|
||
// 启用"——坏文件、缺文件、显式 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))
|
||
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
|
||
}
|
||
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\": {\"<personaId>\": \"<characterId>\"}} so the voice follows the persona")
|
||
}
|
||
return voicetts.Config{
|
||
Enabled: true,
|
||
BaseURL: tuning.BaseURL,
|
||
Personas: tuning.Personas,
|
||
Default: tuning.Default,
|
||
Format: tuning.Format,
|
||
Timeout: time.Duration(tuning.TimeoutSec) * time.Second,
|
||
}, true
|
||
}
|
||
|
||
// voiceFallbackChain 声明 a.voice 满足 emotionTTS 门面要的底链面(编译期断言)。
|
||
// 它把"既有链少了一个方法"这种改动的代价,从**运行期 panic** 提前到**编译失败**。
|
||
var _ voicetts.Chain = (*voice.Chain)(nil)
|