P2-1(实质):主会话(web)的 persona_id 恒为空且无重绑路径,而"空"的真实语义是 「继承全局默认人格」(loop/persona.go 回退 Options.PersonaID;前端 lib/persona.ts 的 bound ?? defaultPersona(personas) 是同一口径)。此前只有 TTS 把空读成"没有生效人格" ⇒ 落到 voice-tts.json 的 default:主会话聊天是 furina、声音却是 default 那个人, 换全局人格后也不跟声 —— 正是主人抱怨的那件事,且发生在唯一能给普通聊天出声的路径。 修法: - turnEnvResolver 新增 resolvePersona/effectivePersona(绑定 → 全局默认人格 → ""), 默认人格扫描抽成 defaultPersonaIDFrom 一处(*Server.defaultPersonaID 转调它); - ownerWithPersona 复用**同一次**会话读取同时给出 loop 用的 binding 与呈现用的 effective —— WS 语音回合为选音色不新增任何 I/O; - REST:现有闸内改用 effectivePersona;WS:voice.go 那次 env 解析处取一次传给 sink。 因此 `default` 只剩"连默认人格都没有"这一种用途。 - service.go 那句"换人格后用老角色声音不可能发生"收回为限定说法:本包无缓存、无兜底 角色、未映射即拒绝,但**调用方必须传生效人格**(网关两处已修)。 回归锁:voice_persona_inherit_test.go 复现复审用例(名字不同、两条口各一条)—— 未绑定会话 + 全局默认 furina + default 指向 cyrene ⇒ 必须用 furina 的声音; 变异(effectivePersona 退回「空 = 无」)实测两条都红,报文与复审一致。 建议 3(顺手做):persona 未映射时状态码仍 500(服务端配置缺失),但错误码换成 tts_persona_unmapped / tts_no_character,不再与一般合成失败混成一个码; 用例 TestSpeechEndpointPersonaUnmappedErrorCode 钉住。 P2-2(nit):voicetts.go 开头删掉「格式、」两字(该键已删除)。 go build ./... + go vet ./... + go test(含 -race)全绿。
186 lines
8.8 KiB
Go
186 lines
8.8 KiB
Go
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)
|
||
}
|