Files
Cyrene_v2/internal/runtime/voice/tts/service.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

173 lines
7.9 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 摆到**既有语音链之前**,并在它失败时回落既有链。
//
// 为什么是"包一层"而不是"在链里插一级":既有链由 `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 是呈现层的增强,不是回复的前置条件)。
//
// 🔴 **发音人按当次请求的 persona 现算**(主人 2026-10-26 的硬要求):每次
// `TTSWithProvider` 都从 ctx 取 personaId、经 `Config.Resolve` 得出 character,
// **没有任何跨请求缓存**。因此"换人格后用老角色的声音"不可能发生。
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() }
// 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` 或既有链的 cloud/local/...)。
//
// 分支(与 `Config.Resolve` 一一对应,验收清单见 selection.go):
// - 选到发音人 ⇒ 用它合成;上游失败 ⇒ 回落既有链 + WARN(口径不变);
// - **选不到发音人**(persona 无映射 / 解析不到且没配 default)⇒ **不合成、
// 也不回落既有链**,直接回一个可分类错误 + 一条 WARN。回落会让她用**另一个
// 声音**说话,正是主人点名的病害;文本早已发出,因此这里"没有声音"是安全结局。
func (s *Service) TTSWithProvider(ctx context.Context, text string) (mvoice.Audio, string, error) {
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
}
return s.base.TTSWithProvider(ctx, text)
}
// degrade 记录一次 emotionTTS 降级:未启用是 DEBUG(正常状态),其余是 WARN
// (异常状态,但已被回落吸收)。日志只带文本长度,**绝不带正文**。
func (s *Service) degrade(ctx context.Context, sel Selection, 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, "persona", sel.PersonaID,
"character", sel.Character, "source", string(sel.Source),
"text_len", len(text), "err", err)
}