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

186 lines
8.8 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.
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 是呈现层的增强,不是回复的前置条件)。
//
// 🔴 **发音人按当次请求的生效人格现算**(主人 2026-10-26 的硬要求):每次
// `TTSWithProvider` 都从 ctx 取 personaId、经 `Config.Resolve` 得出 character,
// **没有任何跨请求缓存**。
//
// 补充限定(复审 r2 P2-1 要求收回原来的绝对说法):本包看到的 personaId 是**调用方
// 解析出来的生效人格**(`chat_sessions.persona_id` → 全局默认人格)。"换人格后仍用
// 老角色声音"在**本包内**不可能发生(无缓存 + 无兜底角色 + 未映射即拒绝);但若
// 调用方把"会话未绑定"当成"没有角色"直接传空串,主会话就会落到配置里的 `default`
// —— 那不是本包能判断的,因此**调用方必须传生效人格**(网关两处已按此修,
// `turnEnvResolver.effectivePersona`)。`default` 的唯一合法用途只剩"连默认人格
// 都没有"这一种情形。
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
// (异常状态,但已被回落吸收)。日志只带文本长度,**绝不带正文**。
//
// attempts / failures 也挂在这条 WARN 上:`Stats()` 若没有消费者,生产日志里就永远
// 看不到"降级过几次",那这个计数等于没做(复审 r1 的 P2-3)。
func (s *Service) degrade(ctx context.Context, sel Selection, text string, err error) {
stats := s.Stats()
if ctx.Err() != nil {
// 回合被取消/超时的情形已由 Client 记过(unreachable),这里只补一句
// 「因此这次没声音」,避免同一条失败刷两条 WARN。
s.log.Debug("voice tts skipped after cancel", "provider", ProviderName,
"text_len", len(text), "attempts", stats.Attempts, "failures", stats.Failures)
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), "attempts", stats.Attempts, "failures", stats.Failures, "err", err)
}