From 9d28477e3af0f09703c772df72c23b4477e144d8 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 28 Oct 2026 17:38:06 +0800 Subject: [PATCH] fix(wire): inject speak VoiceSynth/VoiceStore into loop options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M57/M58 三片 scope 均不含 cmd/cyrene——组合根接线漏了, voiceEnabled() 恒 false 导致 speak 从不被广告(她说不能发语音)。 synth 与网关同配置独立装配(emotionTTS 未启用时 nil=不广告); store 与 channelbridge 同目录同表(引用计数单一事实来源)。 --- cmd/cyrene/wire_runtime.go | 4 +++ cmd/cyrene/wire_speak.go | 58 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 cmd/cyrene/wire_speak.go diff --git a/cmd/cyrene/wire_runtime.go b/cmd/cyrene/wire_runtime.go index fb4c9ed..aeaa307 100644 --- a/cmd/cyrene/wire_runtime.go +++ b/cmd/cyrene/wire_runtime.go @@ -329,6 +329,10 @@ func (a *app) wireRuntime() error { Tracer: tracer, Flow: flowPub, Guards: guards, + // speak 的两个依赖(wire_speak.go):emotionTTS 未启用时 synth 为 nil, + // speak 不广告(voiceEnabled() 要求两者齐备)。 + VoiceSynth: a.speakSynthForLoop(), + VoiceStore: a.speakStoreForLoop(docDataDir), PersonaID: personaID, PersonaName: a.personaName, PersonaKnobs: a.personaKnobs, diff --git a/cmd/cyrene/wire_speak.go b/cmd/cyrene/wire_speak.go new file mode 100644 index 0000000..ce51ddf --- /dev/null +++ b/cmd/cyrene/wire_speak.go @@ -0,0 +1,58 @@ +package main + +// speak 的组合根接线(M57/M58 三片合并后的集成缝隙修复,2026-10-27): +// `loop.VoiceSynth` / `VoiceStore` 两个依赖此前没有任何 wire 层注入 ⇒ +// `voiceEnabled()` 恒 false ⇒ speak 从不被广告(症状:她说"不能发语音"、 +// 查工具清单也看不到 speak)。本文件补上这一跳。 +// +// 语义:speak 依赖 emotionTTS(发音人按人格解析在那条链上)——面板 +// voice-tts.json 未启用时注入 nil,speak 不广告(比"广告了但永远 +// not_configured 降级"诚实)。副本落盘与 channelbridge / 文档上传同源: +// 同一棵 `/uploads/documents` 目录、同一张 documents 表 +// (引用计数与 GC 的单一事实来源不被旁路)。 + +import ( + "context" + "path/filepath" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" + voicetts "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/tts" +) + +const speakWireSource = "cmd/cyrene/speak" + +// speakSynthForLoop 返回注入 loop 的合成能力:emotionTTS 按面板启用时是 +// 一个独立装配的 voicetts.Service(与网关的实例同配置不同对象——装配期 +// 一次性构造,无共享状态竞争);未启用返回 nil(speak 不广告)。 +func (a *app) speakSynthForLoop() loop.VoiceSynth { + cfg, enabled := a.voiceTTSConfig() + if !enabled { + logger.WithSource(a.log, speakWireSource).Debug("speak stays unadvertised: emotionTTS not enabled", + "config", "voice-tts.json", "hint", "set {\"enabled\": true}") + return nil + } + client := voicetts.New(cfg, voicetts.WithLogger(logger.WithSource(a.log, speakWireSource))) + return voicetts.NewService(client, a.voice, voicetts.WithServiceLogger(logger.WithSource(a.log, speakWireSource))) +} + +// speakStoreForLoop 返回注入 loop 的音频副本落盘能力:与 channelbridge 的 +// DocumentStore 同目录同表(wire_channels.go 的口径),经 VoiceStoreFunc +// 做 documents.Ref → loop.VoiceRef 的窄转换(loop 不 import documents, +// 测试期 import cycle 的既定裁决)。 +func (a *app) speakStoreForLoop(docDataDir string) loop.VoiceStore { + store := documents.NewStore(documents.StoreConfig{ + DataDir: docDataDir, + UploadsDir: filepath.Join(docDataDir, "uploads", "documents"), + Meta: documents.MetadataWriterFor(a.dbPath, a.log), + Log: a.log, + }) + return loop.VoiceStoreFunc(func(ctx context.Context, origName string, data []byte) (loop.VoiceRef, error) { + ref, err := store.SaveAudio(ctx, origName, data) + if err != nil { + return loop.VoiceRef{}, err + } + return loop.VoiceRef{Name: ref.Name, MIME: ref.MIME, Size: ref.Size}, nil + }) +}