Files
Cyrene_v2/internal/runtime/voice/tts/service.go
T
AskaEth 8820b0b316 fix(voice): 修 r1 复审四条(正文进日志的隐私泄漏 / format 假开关 / Stats 无消费者 / redactURL 兜底)
上一版把 r1 的三条 finding 静默漏了(我在交付前没读 inbox),本提交逐条补齐:

1. 🔴 隐私:upstreamStatus 不再读也不记响应体
   FastAPI 的 422 会把请求体里的 input 整段回显,原来「错误体前 512 字节进 WARN」
   等于把她的原话写进日志。现在只记 status / content_type / response_bytes
   (字节数优先取 Content-Length,缺失时有界排空取上界,读到即丢)。
   新用例 TestSpeechRejectLogOmitsRequestText 用**回显正文**的假上游抓它;
   变异(把 body 塞回 WARN)实测打红。

2. format 键彻底删除(假开关)
   上游只拿 response_format 改自报 media_type,产物恒 .wav 无转码,我方只按字节
   嗅探裁决 ⇒ wav/mp3/ogg 交付字节与响应头完全相同。删 tts.Config.Format 与
   VoiceTTSTuning.Format,encodeSpeech 硬编码 requestFormat="wav"(不可配置)。
   用例钉住请求体恒为 wav;变异(改成 mp3)打红。旧文件里残留的 format 键被忽略。

3. Stats 接进 degrade 的 WARN(attempts/failures)
   否则统计在生产日志里没有消费者。TestServiceDegradeLogsCounters 钉住;
   变异(去掉两个字段)打红。

4. redactURL 兜底含 "@" 整体打码
   解析失败/无 host 时原来原样返回,user:pass@ 会漏凭据。TestRedactURLCredentials
   覆盖合法 URL 去 userinfo、非法含 @ 打码;变异(退回原样返回)打红。

另:关闭态零行为变化的**当前 tip 重跑**——
- handleSpeech 的 persona 解析改为只在语音服务实现 personaVoice 且 PrimaryActive
  时才查库;未接 emotionTTS(既有链/stub)时零额外 I/O、ctx 不改、音频字节相同。
  用例 TestSpeechEndpointDisabledVoiceDoesNotReadPersona(含对照组证明闸不是恒关);
  变异(去掉闸)打红。
- voice.go 在 dev 基线里本就有 env.PersonaID 解析(无人格解析新增 I/O),本次未改。

Config.Normalized 去掉未使用的 logger 参数。go build/vet/test(含 -race)全绿;
真机 live 复测:cyrene 264104 B、furina 221194 B。
2026-10-26 13:06:39 +08:00

178 lines
8.2 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
// (异常状态,但已被回落吸收)。日志只带文本长度,**绝不带正文**。
//
// 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)
}