Files
Cyrene_v2/internal/runtime/voice/tts/service.go
T
AskaEth 3a2711a7e0 feat(voice): emotionTTS 接成一级发音人(UTF-8 请求体 + 失败回落既有链)
侦察结论(真机 + 上游源码):
- POST /api/analyze_emotion ⇒ {primary,complex,intensity};primary 闭集
  实测 平/喜/惊/哀/怒/厌/惧/急,intensity ∈ Low/Medium/High。
- POST /api/synthesize 收 emo_vector(float[])+emo_alpha,但还要 ref_audio_filename
  且只回 {"audio_url"}(服务端落盘),不适合做运行时直出。
- POST /v1/audio/speech 内部先 LLM 分析情绪 + 匹配参考音,再把情绪向量以
  [EMO:{vector}|{alpha}]base64:... 注入 voice 交给 indexTTS2 ⇒ **情绪确实影响
  语气,但由服务端从待念文本自推**,我方没有可映射的参数。
- 上游把下游载荷的 speed 硬编码 1.0(core/utils/llm_provider.py),客户端
  speed 是空转 ⇒ 本切片不暴露 speed,也不暴露 emotion/tone(不造假开关)。

实现:
- internal/runtime/voice/tts:Client(唯一 HTTP 出口,json.Encoder 产出 UTF-8
  请求体、超时/非 200/非音频/超限各自可分类)+ 字节嗅探庭裁 + Service(emotionTTS
  在前、失败回落既有链,永不上抛成回复失败)+ Provider 适配(可作链的一级)。
- internal/config/runtimeconfig/voicetts.go:$DATA_DIR/config/voice-tts.json
  (enabled/baseUrl/character/format/timeoutSec),零新 .env 键。
- cmd/cyrene/wire_voice_tts.go + wireFrontend 的唯一注入点:两条既有出站口
  (POST /api/v1/audio/speech 与 WS voice_output)同时用上;不新建端点/帧族。
- 严守 voice 入站闭集(不扩 Source/Status),出站音频不挂 voice DTO(塔裁决 B)。

测试:中文 UTF-8 请求体、非 200、超时、非音频、超限、未启用、空文本、嗅探表、
降级回落、未启用透传、两条出站口的字节级实测、面板配置解析、组合根映射;
三条变异(去中文 / 嗅探恒真 / 去掉回落)各自打红。真机 live 用例(build tag
emotiontts_live)实测 278,036 字节 WAV。
2026-10-26 12:46:57 +08:00

162 lines
6.8 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 是呈现层的增强,不是回复的前置条件)。
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() }
// Character 返回生效角色(面板展示用)。
func (s *Service) Character() string {
if s == nil || s.primary == nil {
return ""
}
return s.primary.Character()
}
// 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,失败/未启用则回落既有链,并如实报告实际
// 服务的那一级(`emotiontts` 或既有链的 cloud/local/...)。
//
// 三条纪律:
// - **失败绝不上抛成回复失败**:先回落,回落也失败才把后者的错误交给调用方
// (既有行为逐字不变);
// - **WARN 带来源**:哪一级、什么原因,一条说清(正文绝不进日志,只有长度);
// - **未启用不是故障**:只记 DEBUG,不刷 WARN。
func (s *Service) TTSWithProvider(ctx context.Context, text string) (mvoice.Audio, string, error) {
if s.PrimaryActive() {
audio, err := s.primary.Speech(ctx, text)
if err == nil {
return mvoice.Audio{Format: audio.Format, Data: audio.Data}, ProviderName, nil
}
s.degrade(ctx, text, err)
}
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, 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, "text_len", len(text), "err", err)
}