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

+230
View File
@@ -0,0 +1,230 @@
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)
}
}