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。
This commit is contained in:
1 parent
a895754d55
commit
3a2711a7e0
15 files changed
+1672
-1
No files matched your search
@@ -126,7 +126,11 @@ func (a *app) wireFrontend() error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("gateway: %w", err)
|
||||
}
|
||||
gw.SetVoice(a.voice)
|
||||
// 语音面只在这**一处**注入:emotionTTS 已按面板配置启用时是「emotionTTS +
|
||||
// 既有链」的门面(失败回落既有链),否则就是既有链本身(行为逐字不变)。
|
||||
// 两条既有出站口(POST /api/v1/audio/speech 与 WS voice_output)都读这一份,
|
||||
// 因此不新建端点、不加帧族(切片 M35 红线)。
|
||||
gw.SetVoice(a.voiceForGateway())
|
||||
a.gw = gw // 必须在下面接线 hook 之前赋值——那些 hook 捕获了 a.gw / gw(否则会 nil 接收者 panic)
|
||||
// 可观测性 trace 出口(design/19 §2):网关已存在,其环形缓冲必须由循环产出的
|
||||
// span 喂养,否则管理端 trace/metrics 接口会一直是空的。
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
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(),
|
||||
"character", client.Character(),
|
||||
"format", cfg.Format,
|
||||
"timeout_sec", int(cfg.Timeout/time.Second),
|
||||
"config", runtimeconfig.VoiceTTSConfigRelPath)
|
||||
return service
|
||||
}
|
||||
|
||||
// voiceTTSConfig 读面板配置并映射成 provider 配置。第二个返回值报告"这一级是否
|
||||
// 启用"——坏文件、缺文件、显式 false 都得出 false(并在坏文件时留一条 WARN)。
|
||||
//
|
||||
// 它**不**在这里做"缺省填什么"的裁决:默认值在 `voicetts.Config.Normalized` 一处。
|
||||
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
|
||||
}
|
||||
return voicetts.Config{
|
||||
Enabled: true,
|
||||
BaseURL: tuning.BaseURL,
|
||||
Character: tuning.Character,
|
||||
Format: tuning.Format,
|
||||
Timeout: time.Duration(tuning.TimeoutSec) * time.Second,
|
||||
}, true
|
||||
}
|
||||
|
||||
// voiceFallbackChain 声明 a.voice 满足 emotionTTS 门面要的底链面(编译期断言)。
|
||||
// 它把"既有链少了一个方法"这种改动的代价,从**运行期 panic** 提前到**编译失败**。
|
||||
var _ voicetts.Chain = (*voice.Chain)(nil)
|
||||
@@ -0,0 +1,102 @@
|
||||
package main
|
||||
|
||||
// 本文件锁定切片 M35 在**组合根**上的三条口径(都是"改坏就静默不工作/静默开错"的那种):
|
||||
//
|
||||
// 1. 面板文件缺失 ⇒ **这一级不启用**,返回的仍是既有链本身(行为逐字不变);
|
||||
// 2. 文件里 `enabled:true` ⇒ 返回 emotionTTS 门面,角色/格式/超时按文件来;
|
||||
// 3. 文件坏掉 ⇒ **不启用**(宁可没有这一级,也不拿读不懂的配置去驱动外部服务)。
|
||||
//
|
||||
// 它用 `DefaultVoiceTTSPath` 的真实落点($DATA_DIR/config/voice-tts.json)跑,
|
||||
// 因此也钉住"零新 .env 键":没有任何一处读环境变量。
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
|
||||
voicetts "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/tts"
|
||||
)
|
||||
|
||||
// voiceTTSApp 造一个只够本题用的 app:数据目录 + 一条既有链。
|
||||
func voiceTTSApp(t *testing.T) *app {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
return &app{
|
||||
cfg: &config.Config{DataDir: dir},
|
||||
log: tidyTestLog(),
|
||||
voice: mvoice.New(config.VoiceConfig{}),
|
||||
}
|
||||
}
|
||||
|
||||
// TestVoiceForGateway_DisabledByDefault 钉住"没配过 ⇒ 原样用既有链"。
|
||||
func TestVoiceForGateway_DisabledByDefault(t *testing.T) {
|
||||
a := voiceTTSApp(t)
|
||||
got := a.voiceForGateway()
|
||||
if got != any(a.voice) {
|
||||
t.Fatalf("没配过时不该包装既有链: %T", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVoiceForGateway_WrapsWhenEnabled 钉住"文件启用 ⇒ 门面生效 + 配置逐项读对"。
|
||||
func TestVoiceForGateway_WrapsWhenEnabled(t *testing.T) {
|
||||
a := voiceTTSApp(t)
|
||||
writeVoiceTTSPanel(t, a, `{"enabled":true,"baseUrl":"http://127.0.0.1:9880",
|
||||
"character":"Cyrene","format":"wav","timeoutSec":45}`)
|
||||
|
||||
got := a.voiceForGateway()
|
||||
svc, ok := got.(*voicetts.Service)
|
||||
if !ok {
|
||||
t.Fatalf("启用后应返回 *tts.Service, got %T", got)
|
||||
}
|
||||
if !svc.PrimaryActive() {
|
||||
t.Fatal("面板写了 enabled:true,但这一级没启用")
|
||||
}
|
||||
if svc.Character() != "Cyrene" {
|
||||
t.Errorf("character = %q, want Cyrene(面板值)", svc.Character())
|
||||
}
|
||||
}
|
||||
|
||||
// TestVoiceForGateway_BadFileStaysDisabled 钉住"坏文件 ⇒ 不启用"(绝不半开)。
|
||||
func TestVoiceForGateway_BadFileStaysDisabled(t *testing.T) {
|
||||
a := voiceTTSApp(t)
|
||||
writeVoiceTTSPanel(t, a, `{"enabled":`)
|
||||
|
||||
got := a.voiceForGateway()
|
||||
if _, ok := got.(*voicetts.Service); ok {
|
||||
t.Fatal("坏文件却启用了 emotionTTS(会拿读不懂的配置去调外部服务)")
|
||||
}
|
||||
if got != any(a.voice) {
|
||||
t.Fatalf("坏文件时应原样用既有链, got %T", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVoiceTTSConfig_NoEnvKeys 钉住"零新 .env 键":把相关环境变量设成垃圾值,
|
||||
// 配置解析的结果只由面板文件决定(证明它根本没读环境)。
|
||||
func TestVoiceTTSConfig_NoEnvKeys(t *testing.T) {
|
||||
a := voiceTTSApp(t)
|
||||
t.Setenv("VOICE_TTS_PROVIDER", "emotiontts")
|
||||
t.Setenv("EMOTIONTTS_BASE_URL", "http://evil.invalid")
|
||||
writeVoiceTTSPanel(t, a, `{"enabled":true}`)
|
||||
|
||||
cfg, enabled := a.voiceTTSConfig()
|
||||
if !enabled {
|
||||
t.Fatal("面板启用却没启用")
|
||||
}
|
||||
if cfg.BaseURL != "" {
|
||||
t.Errorf("baseURL = %q,want 空(默认值由 Normalized 补,不读环境)", cfg.BaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
// writeVoiceTTSPanel 把内容写进**真实落点**($DATA_DIR/config/voice-tts.json)。
|
||||
func writeVoiceTTSPanel(t *testing.T, a *app, body string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(a.cfg.DataDir, "config", "voice-tts.json")
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatalf("write panel: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user