侦察结论(真机 + 上游源码):
- 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。
36 lines
1.3 KiB
Go
36 lines
1.3 KiB
Go
package tts
|
|
|
|
import "testing"
|
|
|
|
// TestSniffAudio 锁定字节嗅探的判据表:认得的容器各自给出正确的格式名,
|
|
// 「不是音频」一律 ("" , false) —— 这一条是"绝不把 JSON/HTML 当音频"的地基。
|
|
func TestSniffAudio(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
data []byte
|
|
format string
|
|
ok bool
|
|
}{
|
|
{"wav", wavBytes("x"), "wav", true},
|
|
{"mp3-id3", append([]byte("ID3"), 0, 0, 0, 0, 0, 0), "mp3", true},
|
|
{"mp3-frame", []byte{0xFF, 0xFB, 0x90, 0x00}, "mp3", true},
|
|
{"ogg", []byte("OggS\x00\x02"), "ogg", true},
|
|
{"flac", []byte("fLaC\x00\x00\x00\x22"), "flac", true},
|
|
{"m4a", []byte{0, 0, 0, 0x20, 'f', 't', 'y', 'p', 'M', '4', 'A', ' '}, "m4a", true},
|
|
{"webm", []byte{0x1A, 0x45, 0xDF, 0xA3, 0x01}, "webm", true},
|
|
{"amr", []byte("#!AMR\n"), "amr", true},
|
|
{"json", []byte(`{"detail":"nope"}`), "", false},
|
|
{"html", []byte("<html><body>502</body></html>"), "", false},
|
|
{"empty", nil, "", false},
|
|
{"riff-not-wave", append([]byte("RIFF"), []byte("\x00\x00\x00\x00AVI ")...), "", false},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
format, ok := sniffAudio(c.data)
|
|
if ok != c.ok || format != c.format {
|
|
t.Fatalf("sniffAudio = (%q,%v), want (%q,%v)", format, ok, c.format, c.ok)
|
|
}
|
|
})
|
|
}
|
|
}
|