package main // 本文件把**半云端 emotionTTS** 接成她的一级发音人(切片 M35),并保持三条红线: // // 1. **配置走面板文件** `$DATA_DIR/config/voice-tts.json`(零新 `.env` 键,design/13 // §2.1)。文件缺失/坏掉 ⇒ 这一级**不启用**,既有语音链逐字不变。 // 2. **文本优先**:合成失败只回落既有链 + 一条带来源的 WARN,绝不把"这条回复" // 判成失败(聊天正文早已发出/落库)。 // 3. **不新建端点/帧族**:只换网关 `SetVoice` 的注入点,因此既有两条出站口 // (`POST /api/v1/audio/speech` 与 WS `voice_output`)同时用上它。 // // 为什么包一层而不是插进链里:既有链由 `multimodal/voice.New` 按 env 组装,各级 // 列表不外露;`.env` 已冻结,不能为新能力加键。包装既不动那条链,也让"失败回落" // 发生在**合成入口**这一处,而不是散在两条出站口里。 import ( "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice" voicetts "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/tts" "git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway" ) // voiceTTSSource 是本切片日志的来源标签(AGENTS §10:日志必须标来源)。 const voiceTTSSource = "cmd/cyrene/voice-tts" // voiceForGateway 返回注入给网关的语音服务:emotionTTS 已按面板配置启用时是 // 「emotionTTS + 既有链」的门面,否则**就是既有链本身**(行为逐字不变)。 // // 它只在 `wireFrontend` 接线时调用一次;每次调用都重新读一次面板文件(启动期 // 读一次的口径与 `backthink.json` 一致)。 func (a *app) voiceForGateway() gateway.VoiceService { if a.voice == nil { return nil } cfg, enabled := a.voiceTTSConfig() if !enabled { return a.voice } client := voicetts.New(cfg, voicetts.WithLogger(logger.WithSource(a.log, voiceTTSSource))) service := voicetts.NewService(client, a.voice, voicetts.WithServiceLogger(logger.WithSource(a.log, voiceTTSSource))) logger.WithSource(a.log, voiceTTSSource).Info("emotionTTS voice enabled", "base_url", client.RedactedBaseURL(), "personas", len(cfg.Personas), "default_set", cfg.Default != "", "timeout_sec", int(cfg.Timeout/time.Second), "config", runtimeconfig.VoiceTTSConfigRelPath) return service } // voiceTTSConfig 读面板配置并映射成 provider 配置。第二个返回值报告"这一级是否 // 启用"——坏文件、缺文件、显式 false 都得出 false(并在坏文件时留一条 WARN)。 // // 它**不**在这里做"缺省填什么"的裁决:默认值在 `voicetts.Config.Normalized` 一处。 // 🔴 它也**不**给 personas/default 补任何角色:发音人只能由面板显式给出(全局定死 // 一个 character 正是主人点名的病害)。 func (a *app) voiceTTSConfig() (voicetts.Config, bool) { log := logger.WithSource(a.log, voiceTTSSource) path := runtimeconfig.DefaultVoiceTTSPath(resolveDataDir(a.cfg)) tuning, err := runtimeconfig.LoadVoiceTTSTuning(path) if err != nil { // 坏文件 ⇒ **这一级不启用**:宁可没有它,也不要用一份读不懂的配置去驱动 // 一台外部服务。既有链照旧服务(文本优先)。 log.Warn("voice tts config unreadable, emotionTTS stays disabled", "path", runtimeconfig.VoiceTTSConfigRelPath, "err", err) return voicetts.Config{}, false } if !tuning.EnabledOn() { log.Debug("emotionTTS not enabled in panel config", "path", runtimeconfig.VoiceTTSConfigRelPath, "hint", "set {\"enabled\": true}") return voicetts.Config{}, false } if legacy := tuning.LegacyGlobalCharacter(); legacy != "" { // 旧版的全局 character:**忽略**,但绝不静默 —— 运维需要知道换成 personas。 log.Warn("voice tts config: global `character` is deprecated and ignored", "character", legacy, "config", runtimeconfig.VoiceTTSConfigRelPath, "hint", "use {\"personas\": {\"\": \"\"}} so the voice follows the persona") } return voicetts.Config{ Enabled: true, BaseURL: tuning.BaseURL, Personas: tuning.Personas, Default: tuning.Default, Timeout: time.Duration(tuning.TimeoutSec) * time.Second, }, true } // voiceFallbackChain 声明 a.voice 满足 emotionTTS 门面要的底链面(编译期断言)。 // 它把"既有链少了一个方法"这种改动的代价,从**运行期 panic** 提前到**编译失败**。 var _ voicetts.Chain = (*voice.Chain)(nil)