Files
Cyrene_v2/cmd/cyrene/wire_voice_tts.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

95 lines
4.6 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 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)