Files
Cyrene_v2/internal/config/runtimeconfig/voicetts_test.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

74 lines
2.6 KiB
Go

package runtimeconfig
import (
"errors"
"os"
"path/filepath"
"testing"
)
// TestLoadVoiceTTSTuning 锁定 voice-tts.json 的四条读取口径:
// 缺失 = 没配过(不是错误)、全空白 = 没配过、合法 = 逐键读出、坏文件 = 可分类错误。
func TestLoadVoiceTTSTuning(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "voice-tts.json")
// ① 缺失
if tuning, err := LoadVoiceTTSTuning(path); err != nil || tuning.EnabledOn() {
t.Fatalf("缺失: tuning=%+v err=%v, want 零值 + nil", tuning, err)
}
// ② 全空白
if err := os.WriteFile(path, []byte(" \n\t "), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
if tuning, err := LoadVoiceTTSTuning(path); err != nil || tuning.EnabledOn() {
t.Fatalf("空白: tuning=%+v err=%v, want 零值 + nil", tuning, err)
}
// ③ 合法
body := `{"enabled":true,"baseUrl":"http://127.0.0.1:9880","character":"char_84fb88a3",
"format":"wav","timeoutSec":45}`
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
tuning, err := LoadVoiceTTSTuning(path)
if err != nil {
t.Fatalf("合法文件: %v", err)
}
if !tuning.EnabledOn() || tuning.Character != "char_84fb88a3" || tuning.Format != "wav" || tuning.TimeoutSec != 45 {
t.Fatalf("读取结果不对: %+v", tuning)
}
// ④ 显式 false 与"没配"同效,但指针让它在读取侧可区分。
if err := os.WriteFile(path, []byte(`{"enabled":false}`), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
if tuning, err = LoadVoiceTTSTuning(path); err != nil || tuning.EnabledOn() || tuning.Enabled == nil {
t.Fatalf("显式 false: tuning=%+v err=%v", tuning, err)
}
// ⑤ 坏文件 ⇒ 可分类错误(调用方据此回落"不启用",且绝不覆盖原文件)
if err := os.WriteFile(path, []byte(`{"enabled":`), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
if _, err := LoadVoiceTTSTuning(path); !errors.Is(err, ErrInvalidVoiceTTS) {
t.Fatalf("坏文件 err = %v, want ErrInvalidVoiceTTS", err)
}
if raw, err := os.ReadFile(path); err != nil || string(raw) != `{"enabled":` {
t.Fatalf("坏文件被覆盖了: %q err=%v", string(raw), err)
}
}
// TestDefaultVoiceTTSPath 钉住落点:`$DATA_DIR/config/voice-tts.json`(零新 .env 键)。
func TestDefaultVoiceTTSPath(t *testing.T) {
got := DefaultVoiceTTSPath(filepath.Join("data", "root"))
want := filepath.Join("data", "root", "config", "voice-tts.json")
if got != want {
t.Fatalf("path = %q, want %q", got, want)
}
if VoiceTTSConfigRelPath != "config/voice-tts.json" {
t.Fatalf("RelPath = %q", VoiceTTSConfigRelPath)
}
}