Files
Cyrene_v2/internal/services/gateway/voice_emotiontts_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

146 lines
5.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}