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:
AskaEth committed 2026-10-26 12:46:57 +08:00
1 parent a895754d55
commit 3a2711a7e0
15 files changed
+1672 -1

No files matched your search

@@ -0,0 +1,145 @@
package gateway
// 本文件是切片 M35 的**两条既有出站口实测**:把真的 emotionTTS provider
// (指向一个 httptest 上游)注入网关,然后
//
// 1. `POST /api/v1/audio/speech` 回的**是音频字节**(而不是 JSON 信封/空体);
// 2. WS `voice_output` 帧里带的**是那段音频的 base64**。
//
// 它钉的是"接上 provider ≠ 用户能听到"这件事:两条口各自的输出都被逐字节断言过。
import (
"context"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
voicetts "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/tts"
)
// fakeWAV 是一段魔数合法的假 WAV(本文件的断言只关心"字节原样跑到客户端")。
func fakeWAV() []byte {
b := []byte("RIFF\x00\x00\x00\x00WAVEfmt ")
b = append(b, make([]byte, 16)...)
b = append(b, []byte("data\x00\x00\x00\x00")...)
return append(b, []byte("her-emotion-voice")...)
}
// emotionTTSServer 起一台假的 emotionTTS:校验请求是 UTF-8 JSON,回一段 WAV。
func emotionTTSServer(t *testing.T) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/audio/speech" {
t.Errorf("path = %q, want /v1/audio/speech", r.URL.Path)
}
var payload struct {
Model string `json:"model"`
Input string `json:"input"`
Voice string `json:"voice"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Errorf("请求体不是合法 UTF-8 JSON: %v", err)
}
if payload.Voice != voicetts.DefaultCharacter || payload.Model != "emotionTTS" {
t.Errorf("payload = %+v, want emotionTTS / 默认角色", payload)
}
w.Header().Set("Content-Type", "audio/wav")
_, _ = w.Write(fakeWAV())
}))
t.Cleanup(srv.Close)
return srv
}
// emotionTTSService 用假上游构造一个**真的** tts.Service(不是 stub)。
func emotionTTSService(t *testing.T, base voicetts.Chain) *voicetts.Service {
t.Helper()
srv := emotionTTSServer(t)
client := voicetts.New(voicetts.Config{Enabled: true, BaseURL: srv.URL})
return voicetts.NewService(client, base)
}
// TestSpeechEndpointServesEmotionTTSAudio 是 REST 口(`POST /api/v1/audio/speech`)
// 的实测证据:状态 200、Content-Type 音频、体就是上游那串字节。
func TestSpeechEndpointServesEmotionTTSAudio(t *testing.T) {
env := newEnv(t, nil, false)
env.srv.SetVoice(emotionTTSService(t, nil))
token := env.token(t, auth.RoleOwner)
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/audio/speech", map[string]any{
"text": "你好,我是苏梦。",
}, token)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, body = %s", resp.StatusCode, body)
}
if ct := resp.Header.Get("Content-Type"); ct != "audio/wav" {
t.Errorf("Content-Type = %q, want audio/wav", ct)
}
if len(body) == 0 {
t.Fatal("响应体为空:用户听不到任何声音")
}
if string(body) != string(fakeWAV()) {
t.Fatalf("body 不是上游音频字节: %d bytes", len(body))
}
}
// TestVoiceOutputFrameCarriesEmotionTTSAudio 是 WS 口(`voice_output` 帧)的实测
// 证据:帧里 audio 字段解码后就是上游那串字节,且 text/reply 都在。
func TestVoiceOutputFrameCarriesEmotionTTSAudio(t *testing.T) {
hb := newTestHub(&stubRunner{res: &loop.Result{Text: "你好,我是苏梦。", Steps: 1, StopReason: "stop"}}, nil)
hb.voice = emotionTTSService(t, &silentBase{})
c := newOwnerConn(hb, 32)
hb.register(c)
c.route(wsInbound{Type: "voice_input", Data: json.RawMessage(`{"text":"你好","format":"wav"}`)})
ev := readFakeFrame(t, c)
if ev.Type != "voice_output" {
t.Fatalf("frame = %q, want voice_output (%+v)", ev.Type, ev)
}
data, ok := ev.Data.(map[string]any)
if !ok {
t.Fatalf("frame data = %T", ev.Data)
}
encoded, _ := data["audio"].(string)
if encoded == "" {
t.Fatal("帧里没有 audio:用户听不到任何声音")
}
raw, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
t.Fatalf("audio 不是合法 base64: %v", err)
}
if string(raw) != string(fakeWAV()) {
t.Fatalf("帧里的音频不是上游字节: %d bytes", len(raw))
}
if data["reply"] != "你好,我是苏梦。" {
t.Errorf("reply = %v, want 助手正文", data["reply"])
}
}
// silentBase 是最小的底链替身:只在测"主路健康"时用作类型占位,
// 一旦被调用就说明主路降级了(测试会因此失败)。
type silentBase struct{}
func (s *silentBase) STT(_ context.Context, _ mvoice.Audio) (string, error) {
return "", mvoice.ErrProviderFailed
}
func (s *silentBase) STTWithProvider(_ context.Context, _ mvoice.Audio) (string, string, error) {
return "", "", mvoice.ErrProviderFailed
}
func (s *silentBase) TTS(_ context.Context, _ string) (mvoice.Audio, error) {
return mvoice.Audio{}, mvoice.ErrProviderFailed
}
func (s *silentBase) TTSWithProvider(_ context.Context, _ string) (mvoice.Audio, string, error) {
return mvoice.Audio{}, "", mvoice.ErrProviderFailed
}
func (s *silentBase) CloneVoice(_ context.Context, _, _ string) (string, error) {
return "", mvoice.ErrProviderFailed
}