Files
Cyrene_v2/internal/runtime/voice/tts/client_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

231 lines
9.1 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 tts
// 本文件锁定 `Client` 的五条对外口径(每条都能被"改坏即打红"):
//
// 1. 🔴 **中文正文必须以 UTF-8 送出**(塔实测:GBK 字节会让服务端回
// `400 {"detail":"There was an error parsing the body"}`)。用例既断言服务端能按
// UTF-8 解出**逐字相等**的正文,也断言请求字节里能直接搜到那段中文 —— 后者是
// 把"改成字符串拼接/转码"这类改动的代价变成红灯的钉子。
// 2. 非 200 ⇒ `ErrUpstream`(且**错误里不带正文**)。
// 3. 超时 ⇒ `ErrUnreachable` 且保留 `context.DeadlineExceeded`(可 errors.Is)。
// 4. 200 但不是音频 ⇒ `ErrNotAudio`(绝不把 JSON/HTML 错误页当音频下发)。
// 5. 未启用 / 空文本 / 超限各自有可分类的哨兵。
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
)
// chineseSpeech 是塔实测那台服务能合成的那句话(保留全角标点,逐字比对)。
const chineseSpeech = "你好,我是苏梦。今天心情特别好,我们一起出去玩吧!"
// wavBytes 造一段**魔数正确**的假 WAV(嗅探只看头,内容是什么不影响判据)。
func wavBytes(payload string) []byte {
b := make([]byte, 0, 44+len(payload))
b = append(b, []byte("RIFF")...)
b = append(b, 0, 0, 0, 0)
b = append(b, []byte("WAVEfmt ")...)
b = append(b, bytes.Repeat([]byte{0}, 16)...)
b = append(b, []byte("data")...)
b = append(b, 0, 0, 0, 0)
return append(b, []byte(payload)...)
}
// newTestClient 指向一个 httptest 服务;可变选项留给各用例。
func newTestClient(t *testing.T, handler http.HandlerFunc, opts ...Option) *Client {
t.Helper()
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
base := []Option{WithLogger(discardLogger())}
base = append(base, opts...)
return New(Config{Enabled: true, BaseURL: srv.URL, Timeout: 2 * time.Second}, base...)
}
// TestSpeechSendsUTF8ChineseText 是**本切片最重要的一条**:中文正文必须按 UTF-8
// 字节、以 `application/json` 送出,且字段名/取值与上游 openapi 逐字一致。
func TestSpeechSendsUTF8ChineseText(t *testing.T) {
var gotPayload speechPayload
var gotRaw []byte
var gotCT string
client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
gotCT = r.Header.Get("Content-Type")
gotRaw, _ = io.ReadAll(r.Body)
if err := json.Unmarshal(gotRaw, &gotPayload); err != nil {
t.Errorf("请求体不是合法 JSON(说明有人改成了拼接/转码): %v", err)
}
w.Header().Set("Content-Type", "audio/wav")
_, _ = w.Write(wavBytes("ok"))
})
audio, err := client.Speech(context.Background(), chineseSpeech)
if err != nil {
t.Fatalf("Speech: %v", err)
}
if gotCT != "application/json" {
t.Errorf("Content-Type = %q, want application/json", gotCT)
}
// ① 服务端按 UTF-8 解出来必须**逐字相等**(GBK 字节在这里必然对不上)。
if gotPayload.Input != chineseSpeech {
t.Fatalf("input = %q, want %q", gotPayload.Input, chineseSpeech)
}
// ② 请求字节里必须能直接搜到那段中文的 UTF-8 编码 —— 一旦有人改成 GBK 转码,
// 这里立刻红(UTF-8 的"你好"= E4BD A0 E5A5 BD)。
if !bytes.Contains(gotRaw, []byte(chineseSpeech)) {
t.Fatalf("请求体里找不到 UTF-8 正文(字节被转码了?): %q", string(gotRaw))
}
if !bytes.Contains(gotRaw, []byte("\xe4\xbd\xa0\xe5\xa5\xbd")) {
t.Fatalf("请求体里找不到「你好」的 UTF-8 字节")
}
if gotPayload.Model != modelName {
t.Errorf("model = %q, want %q", gotPayload.Model, modelName)
}
if gotPayload.Voice != DefaultCharacter {
t.Errorf("voice = %q, want %q", gotPayload.Voice, DefaultCharacter)
}
if gotPayload.ResponseFormat != DefaultFormat {
t.Errorf("response_format = %q, want %q", gotPayload.ResponseFormat, DefaultFormat)
}
// ③ 回发的格式由**字节嗅探**裁决,不是请求里那个字段。
if audio.Format != "wav" || len(audio.Data) == 0 {
t.Fatalf("audio = %+v, want wav with bytes", audio)
}
}
// TestSpeechHTMLEscapingDisabled 钉住"正文逐字不变":`<`/`>`/`&` 不得被改写成 \u003c。
func TestSpeechHTMLEscapingDisabled(t *testing.T) {
var gotRaw []byte
client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
gotRaw, _ = io.ReadAll(r.Body)
_, _ = w.Write(wavBytes("ok"))
})
if _, err := client.Speech(context.Background(), "a<b & c>d"); err != nil {
t.Fatalf("Speech: %v", err)
}
if bytes.Contains(gotRaw, []byte(`\u003c`)) || bytes.Contains(gotRaw, []byte(`\u0026`)) {
t.Fatalf("正文被 HTML 转义了: %q", string(gotRaw))
}
}
// TestSpeechRejectsNon200 钉住上游拒绝:可分类、带状态码、**不带正文**。
func TestSpeechRejectsNon200(t *testing.T) {
client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"detail":"There was an error parsing the body"}`))
})
_, err := client.Speech(context.Background(), chineseSpeech)
if !errors.Is(err, ErrUpstream) {
t.Fatalf("err = %v, want ErrUpstream", err)
}
if !strings.Contains(err.Error(), "400") {
t.Errorf("错误里应带状态码: %v", err)
}
if strings.Contains(err.Error(), "苏梦") {
t.Errorf("错误链里出现了正文(红线:日志/错误不带正文): %v", err)
}
if got := client.Stats(); got.Attempts != 1 || got.Failures != 1 {
t.Errorf("stats = %+v, want 1 attempt / 1 failure", got)
}
}
// TestSpeechTimeout 钉住超时:归 ErrUnreachable,且因果链仍能判出 deadline。
func TestSpeechTimeout(t *testing.T) {
release := make(chan struct{})
t.Cleanup(func() { close(release) })
handler := func(w http.ResponseWriter, r *http.Request) {
select {
case <-release:
case <-time.After(2 * time.Second):
}
_, _ = w.Write(wavBytes("late"))
}
client := newTestClient(t, handler, WithHTTPClient(&http.Client{Timeout: 60 * time.Millisecond}))
start := time.Now()
_, err := client.Speech(context.Background(), chineseSpeech)
if !errors.Is(err, ErrUnreachable) {
t.Fatalf("err = %v, want ErrUnreachable", err)
}
if !errors.Is(err, context.DeadlineExceeded) {
t.Errorf("err = %v, want wraps context.DeadlineExceeded", err)
}
if elapsed := time.Since(start); elapsed > time.Second {
t.Errorf("超时没有生效(用了 %v)", elapsed)
}
}
// TestSpeechRejectsNonAudio 钉住"200 但不是音频":JSON 错误页绝不当音频。
func TestSpeechRejectsNonAudio(t *testing.T) {
client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"success","audio_url":"/outputs/x.wav"}`))
})
_, err := client.Speech(context.Background(), chineseSpeech)
if !errors.Is(err, ErrNotAudio) {
t.Fatalf("err = %v, want ErrNotAudio", err)
}
if got := client.Stats(); got.Failures != 1 {
t.Errorf("stats = %+v, want 1 failure", got)
}
}
// TestSpeechRejectsOversizeResponse 钉住体积上限。
func TestSpeechRejectsOversizeResponse(t *testing.T) {
client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(wavBytes(strings.Repeat("x", 128)))
}, WithMaxResponseBytes(32))
_, err := client.Speech(context.Background(), chineseSpeech)
if !errors.Is(err, ErrTooLarge) {
t.Fatalf("err = %v, want ErrTooLarge", err)
}
}
// TestSpeechGuards 钉住"不该发网络"的两条前置拒绝,以及"未启用"的可区分性。
func TestSpeechGuards(t *testing.T) {
var hits atomic.Int64
client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
_, _ = w.Write(wavBytes("ok"))
})
if _, err := client.Speech(context.Background(), " "); !errors.Is(err, ErrEmptyText) {
t.Errorf("空文本 err = %v, want ErrEmptyText", err)
}
disabled := New(Config{Enabled: false, BaseURL: client.cfg.BaseURL}, WithLogger(discardLogger()))
if _, err := disabled.Speech(context.Background(), chineseSpeech); !errors.Is(err, ErrDisabled) {
t.Errorf("未启用 err = %v, want ErrDisabled", err)
}
// 被拒的两次都不该打网络;未启用也不该被计成"失败"。
if got := hits.Load(); got != 0 {
t.Errorf("上游被调用 %d 次,want 0", got)
}
if got := disabled.Stats(); got.Attempts != 0 || got.Failures != 0 {
t.Errorf("未启用却计了数: %+v", got)
}
}
// TestConfigNormalized 钉住默认值与非法格式的回落(不让一个写错的格式关掉语音面)。
func TestConfigNormalized(t *testing.T) {
got := Config{}.Normalized(discardLogger())
if got.Enabled {
t.Errorf("零值配置不该被悄悄启用")
}
if got.BaseURL != DefaultBaseURL || got.Character != DefaultCharacter ||
got.Format != DefaultFormat || got.Timeout != DefaultTimeout {
t.Fatalf("默认值不对: %+v", got)
}
if custom := (Config{BaseURL: " http://127.0.0.1:9880/ ", Format: "MP3"}).Normalized(discardLogger()); custom.BaseURL != DefaultBaseURL || custom.Format != "mp3" {
t.Errorf("归一化不对: %+v", custom)
}
if bad := (Config{Format: "exe"}).Normalized(discardLogger()); bad.Format != DefaultFormat {
t.Errorf("非法格式应回落默认: %+v", bad)
}
}