Files
Cyrene_v2/internal/runtime/voice/tts/client_test.go
T
AskaEth efc25c6a7f feat(voice): emotionTTS 发音人绑定会话人格(不再全局定死一个 character)
主人 2026-10-26 逐字:「TTS 的人物选择是要绑定后端的角色的,不然会出现以后换了
别的角色(人格配置),还是用老角色的 TTS 角色」——上一版把 character 写在面板文件
里当全局值,换人格后声音不变,是真缺陷。

配置形状(单一真源 = $DATA_DIR/config/voice-tts.json):
  {"enabled":true,
   "personas":{"<personaId>":"<characterId>"},
   "default":"<characterId>",   // 只服务「解析不到 personaId」
   "format":"wav","timeoutSec":60}
- 旧的全局 `character` 键**废弃并被忽略**(启动 WARN 提示改用 personas);
  Config.Normalized 不给任何角色兜底(代码里写死默认角色=病害本身);
- configs/personas/*.yaml 不参与(塔裁定:避免启动期静态配置与面板两处漂移);
- 零新 .env 键。

选角四条分支(Config.Resolve,纯函数、无缓存):
  ① persona 有映射 ⇒ 用它;
  ② personaId 为空/解析不到 + 配了 default ⇒ 用 default;
  ③ personaId 为空 + 没配 default ⇒ 不合成(ErrNoCharacter,上游 0 请求);
  ④ persona 无映射(含映射值为空)⇒ 拒绝(ErrPersonaUnmapped),
     **绝不**拿 default 或别的角色顶 —— 主人担心的病害本身。

两条既有出站口各自按当次请求解析 persona(不在 SetVoice 时算一次缓存成全局):
- WS voice_output:用本回合的 env.PersonaID(runVoiceTurn/runVoiceTurnDirect),
  经 voiceReplySink 传给 synthesizeVoiceReply,再 WithPersonaID 交给语音面;
- POST /api/v1/audio/speech:请求体新增**可选** sessionId(纯增量),
  经 turnEnvResolver.sessionPersona 解析;省略 ⇒ 走 default 分支。

Client.Speech 改为**每次传入 character**(内部不缓存发音人);Provider 适配同样按
ctx 的 personaId 现算。选不到发音人时**不回落既有链**(回落=用另一个声音说话),
上游拒绝(404/500)仍按既有口径回落既有链+WARN。

测试:四条分支各自可变异打红,断言打在「上游请求体里的 voice」与帧/响应字节上;
含「同一进程先后两个 persona,第二次绝不沿用第一次 voice」的回归锁,
以及两条出站口的端到端 persona 切换。四条变异(未映射回落 default / 客户端缓存
voice / REST 不看会话 / 选不到时回落底链)均已实测打红后还原。
go build ./... / go vet ./... / go test -race 全绿;真机 live 证据:
cyrene(char_84fb88a3)=254630 B、furina(char_5314489e)=213392 B。
2026-10-26 12:57:35 +08:00

241 lines
10 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(), DefaultCharacter, 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(), DefaultCharacter, "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(), DefaultCharacter, 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(), DefaultCharacter, 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(), DefaultCharacter, 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(), DefaultCharacter, 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(), DefaultCharacter, " "); !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(), DefaultCharacter, 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.Format != DefaultFormat || got.Timeout != DefaultTimeout {
t.Fatalf("默认值不对: %+v", got)
}
if got.Default != "" || len(got.Personas) != 0 {
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)
}
// persona/character 两端的空白被清掉;空 personaId 的键被丢弃。
trimmed := (Config{Personas: map[string]string{" a ": " char_1 ", "": "char_2"}, Default: " char_3 "}).Normalized(discardLogger())
if trimmed.Personas["a"] != "char_1" || len(trimmed.Personas) != 1 || trimmed.Default != "char_3" {
t.Fatalf("映射/默认值没有被归一化: %+v", trimmed)
}
}