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。
This commit is contained in:
1 parent
3a2711a7e0
commit
efc25c6a7f
16 files changed
+1210
-503
No files matched your search
@@ -123,9 +123,6 @@ func (c *Client) Config() Config { return c.cfg }
|
||||
// Enabled 报告这一级是否启用。
|
||||
func (c *Client) Enabled() bool { return c.cfg.Enabled }
|
||||
|
||||
// Character 返回生效角色(默认 `char_84fb88a3`)。
|
||||
func (c *Client) Character() string { return c.cfg.Character }
|
||||
|
||||
// RedactedBaseURL 返回**可安全打日志**的服务地址(只保留 scheme 与 host ——
|
||||
// 运维若在地址里塞了凭据,日志不该把它写出来)。
|
||||
func (c *Client) RedactedBaseURL() string { return redactURL(c.cfg.BaseURL) }
|
||||
@@ -135,20 +132,29 @@ func (c *Client) Stats() Stats {
|
||||
return Stats{Attempts: c.attempts.Load(), Failures: c.failures.Load()}
|
||||
}
|
||||
|
||||
// Speech 把文本合成成音频。这是本包的**唯一网络出口**:`Service`(降级包装)与
|
||||
// 既有语音链的 `TTS` 形状都走它,绝不存在第二处 HTTP 调用。
|
||||
// Speech 把文本用**指定发音人**合成成音频。这是本包的**唯一网络出口**:
|
||||
// `Service`(降级包装)与既有语音链的 `TTS` 形状都走它,绝不存在第二处 HTTP 调用。
|
||||
//
|
||||
// `character` 是**每次调用显式传入**的,客户端内部**不缓存**任何发音人 —— 因此
|
||||
// 同一进程先后服务两个角色,第二次绝不会沿用第一次的 voice(主人 2026-10-26 的
|
||||
// 硬要求;由 `Service` 按每次请求的 personaId 现算,见 selection.go)。
|
||||
// 空 character 一律拒绝:宁可没有声音,也不要用一个"默认"角色的声音冒充她。
|
||||
//
|
||||
// 返回值里的 `Format` 是**字节嗅探**所得,不是请求里那个 `response_format`。
|
||||
func (c *Client) Speech(ctx context.Context, text string) (Audio, error) {
|
||||
func (c *Client) Speech(ctx context.Context, character, text string) (Audio, error) {
|
||||
if !c.cfg.Enabled {
|
||||
return Audio{}, ErrDisabled
|
||||
}
|
||||
voice := strings.TrimSpace(character)
|
||||
if voice == "" {
|
||||
return Audio{}, ErrNoCharacter
|
||||
}
|
||||
trimmed := strings.TrimSpace(text)
|
||||
if trimmed == "" {
|
||||
return Audio{}, ErrEmptyText
|
||||
}
|
||||
c.attempts.Add(1)
|
||||
audi, err := c.speak(ctx, trimmed)
|
||||
audi, err := c.speak(ctx, voice, trimmed)
|
||||
if err != nil {
|
||||
c.failures.Add(1)
|
||||
}
|
||||
@@ -156,8 +162,8 @@ func (c *Client) Speech(ctx context.Context, text string) (Audio, error) {
|
||||
}
|
||||
|
||||
// speak 是 Speech 的主体:编码 → 发送 → 判状态 → 嗅探。失败原因已分类。
|
||||
func (c *Client) speak(ctx context.Context, text string) (Audio, error) {
|
||||
body, err := encodeSpeech(text, c.cfg.Character, c.cfg.Format)
|
||||
func (c *Client) speak(ctx context.Context, character, text string) (Audio, error) {
|
||||
body, err := encodeSpeech(text, character, c.cfg.Format)
|
||||
if err != nil {
|
||||
return Audio{}, err
|
||||
}
|
||||
|
||||
@@ -1,230 +1,240 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -38,8 +38,12 @@ const (
|
||||
// DefaultBaseURL 是该服务的默认地址(**只绑回环**、无鉴权:绝不是公网地址)。
|
||||
DefaultBaseURL = "http://127.0.0.1:9880"
|
||||
|
||||
// DefaultCharacter 是默认角色 —— 主人指定的「Cyrene」(`GET /api/characters`
|
||||
// 实测 id = `char_84fb88a3`)。
|
||||
// DefaultCharacter 只是**示例/文档用**的角色 id(主人指定的「Cyrene」,
|
||||
// `GET /api/characters` 实测 id = `char_84fb88a3`)。
|
||||
//
|
||||
// 🔴 它**不是**代码里的兜底发音人:把任何角色 id 写死在代码里,正是主人
|
||||
// 2026-10-26 点出的病害(换了人格仍用老角色的声音)。发音人一律由
|
||||
// `Config.Personas` / `Config.Default` 显式给出(见 Resolve)。
|
||||
DefaultCharacter = "char_84fb88a3"
|
||||
|
||||
// DefaultFormat 是默认请求格式。服务端产物**恒为 WAV**(它把 .wav 落盘后用
|
||||
@@ -66,14 +70,23 @@ const (
|
||||
// `$DATA_DIR/config/voice-tts.json` 映射而来;零新 `.env` 键)。
|
||||
//
|
||||
// 🔴 刻意**没有** speed / emotion / tone 三项,理由见包注释(上游硬编码或自推)。
|
||||
// 🔴 也**没有全局 character**:发音人只能由 `Personas`(按会话角色映射)或
|
||||
// `Default`(仅服务"解析不到 personaId")给出 —— 主人 2026-10-26 的硬要求
|
||||
// 「TTS 的人物选择要绑定后端的角色,不然换了人格还是用老角色的声音」。
|
||||
type Config struct {
|
||||
// Enabled 是总开关。false = 该级整体不参与(调用方应保留既有链,
|
||||
// 而不是把"没启用"当成"合成失败")。
|
||||
Enabled bool
|
||||
// BaseURL 是服务地址;空 = DefaultBaseURL。约定只填回环地址。
|
||||
BaseURL string
|
||||
// Character 是角色(服务端 `/api/characters` 的 id 或名字);空 = DefaultCharacter。
|
||||
Character string
|
||||
// Personas 是 **personaId → 上游 characterId** 的唯一映射源(也接受
|
||||
// `char_` 前缀或不带前缀的写法,见 Resolve)。它有键但值为空 = 该角色
|
||||
// 没有可用发音人 ⇒ 不合成(**绝不**借用别的角色的声音)。
|
||||
Personas map[string]string
|
||||
// Default 是**仅当 personaId 解析不到**(空串:会话无绑定 / 读取失败 /
|
||||
// 该出口本身没有会话)时使用的发音人。它**不**服务"配了 Personas 但没有
|
||||
// 这个 persona 的键"那种情况 —— 那是 Resolve 的拒绝分支(主人点名的病害)。
|
||||
Default string
|
||||
// Format 是请求格式(`response_format`);空 = DefaultFormat。只接受
|
||||
// wav/mp3/ogg —— 且**只用于请求**,回发的真实格式一律按字节嗅探。
|
||||
Format string
|
||||
@@ -88,16 +101,17 @@ var supportedFormats = map[string]bool{"wav": true, "mp3": true, "ogg": true}
|
||||
|
||||
// Normalized 返回填好默认值、去掉首尾空白的配置。非法 Format 回落到默认值并
|
||||
// 通过 log 记一条 WARN(**不让它停摆** —— 一个写错的格式不该关掉整个语音面)。
|
||||
//
|
||||
// 它**不**给 `Personas`/`Default` 补任何默认角色:发音人只有两种合法来源
|
||||
// (显式映射 / 显式 default),代码里的兜底值就是主人担心的病害本身。
|
||||
func (c Config) Normalized(log *slog.Logger) Config {
|
||||
out := c
|
||||
out.BaseURL = strings.TrimRight(strings.TrimSpace(out.BaseURL), "/")
|
||||
if out.BaseURL == "" {
|
||||
out.BaseURL = DefaultBaseURL
|
||||
}
|
||||
out.Character = strings.TrimSpace(out.Character)
|
||||
if out.Character == "" {
|
||||
out.Character = DefaultCharacter
|
||||
}
|
||||
out.Personas = normalizePersonas(out.Personas)
|
||||
out.Default = strings.TrimSpace(out.Default)
|
||||
out.Format = strings.ToLower(strings.TrimSpace(out.Format))
|
||||
if out.Format == "" {
|
||||
out.Format = DefaultFormat
|
||||
@@ -113,3 +127,22 @@ func (c Config) Normalized(log *slog.Logger) Config {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// normalizePersonas 去掉映射两端的空白与空键(空键不可能是合法 personaId),
|
||||
// 并保留"键在、值为空"这一形态 —— 它表示"这个角色明确没有发音人",与"没这个键"
|
||||
// 在 Resolve 里是**不同**的分支(前者拒绝,后者也拒绝;两者都绝不借用别的角色)。
|
||||
func normalizePersonas(in map[string]string) map[string]string {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(in))
|
||||
for persona, character := range in {
|
||||
key := strings.TrimSpace(persona)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
out[key] = strings.TrimSpace(character)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -2,6 +2,17 @@
|
||||
|
||||
package tts
|
||||
|
||||
// 本文件是**对着真的 emotionTTS 服务**的端到端验证,默认不进 `go test ./...`
|
||||
// (与 `dashscope_live` 同一口径:不得让常规测试去碰外部服务)。
|
||||
//
|
||||
// 显式运行(服务默认在 127.0.0.1:9880,可用 EMOTIONTTS_BASE_URL 覆盖):
|
||||
//
|
||||
// go test -tags emotiontts_live -run TestLiveSpeech -v ./internal/runtime/voice/tts/
|
||||
//
|
||||
// 🔴 第二条用例(TestLiveSpeechFollowsPersona)是主人 2026-10-26 那条硬要求的
|
||||
// **真机证据**:同一份配置、同一进程,两个 persona 各自合成一次 —— 两次用的是
|
||||
// 两个不同的上游角色,音频字节也不同。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
@@ -9,20 +20,53 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// 本文件是**对着真的 emotionTTS 服务**的端到端验证,默认不进 `go test ./...`
|
||||
// (与 `dashscope_live` 同一口径:不得让常规测试去碰外部服务)。
|
||||
//
|
||||
// 显式运行(服务默认在 127.0.0.1:9880,可用 EMOTIONTTS_BASE_URL 覆盖):
|
||||
//
|
||||
// go test -tags emotiontts_live -run TestLiveSpeech -v ./internal/runtime/voice/tts/
|
||||
// TestLiveSpeech 用默认角色合成一句话,验证拿到的确实是 WAV。
|
||||
func TestLiveSpeech(t *testing.T) {
|
||||
baseURL := os.Getenv("EMOTIONTTS_BASE_URL")
|
||||
client := New(Config{Enabled: true, BaseURL: baseURL, Timeout: 120 * time.Second})
|
||||
client := New(Config{Enabled: true, BaseURL: os.Getenv("EMOTIONTTS_BASE_URL"), Timeout: 120 * time.Second})
|
||||
audio, elapsed := synthesizeOrFail(t, client, DefaultCharacter)
|
||||
t.Logf("evidence: base=%s character=%s bytes=%d format=%s elapsed=%s",
|
||||
client.Config().BaseURL, DefaultCharacter, len(audio.Data), audio.Format, elapsed)
|
||||
}
|
||||
|
||||
// TestLiveSpeechFollowsPersona 是真机的"换人格就换声音"证据:两个 persona 分别映射
|
||||
// 到服务端真实存在的两个角色(Cyrene / 芙宁娜),各自合成同一句话。
|
||||
func TestLiveSpeechFollowsPersona(t *testing.T) {
|
||||
cfg := Config{
|
||||
Enabled: true,
|
||||
BaseURL: os.Getenv("EMOTIONTTS_BASE_URL"),
|
||||
Timeout: 120 * time.Second,
|
||||
Personas: map[string]string{
|
||||
"cyrene": "char_84fb88a3",
|
||||
"furina": "char_5314489e",
|
||||
},
|
||||
}
|
||||
client := New(cfg)
|
||||
sizes := make(map[string]int, 2)
|
||||
for _, persona := range []string{"cyrene", "furina"} {
|
||||
selection, err := client.Config().Resolve(persona)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: resolve: %v", persona, err)
|
||||
}
|
||||
audio, elapsed := synthesizeOrFail(t, client, selection.Character)
|
||||
sizes[selection.Character] = len(audio.Data)
|
||||
t.Logf("evidence: persona=%s character=%s source=%s bytes=%d elapsed=%s",
|
||||
persona, selection.Character, selection.Source, len(audio.Data), elapsed)
|
||||
}
|
||||
if sizes["char_84fb88a3"] == 0 || sizes["char_5314489e"] == 0 {
|
||||
t.Fatalf("两个角色没有都产出音频: %v", sizes)
|
||||
}
|
||||
if sizes["char_84fb88a3"] == sizes["char_5314489e"] {
|
||||
t.Logf("note: 两个角色的音频字节数恰好相同(%d),这可能只是巧合,不改变 voice 已按 persona 切换的事实", sizes["char_84fb88a3"])
|
||||
}
|
||||
}
|
||||
|
||||
// synthesizeOrFail 合成一次并做最小校验,返回音频与耗时。
|
||||
func synthesizeOrFail(t *testing.T, client *Client, character string) (Audio, time.Duration) {
|
||||
t.Helper()
|
||||
start := time.Now()
|
||||
audio, err := client.Speech(context.Background(), chineseSpeech)
|
||||
audio, err := client.Speech(context.Background(), character, chineseSpeech)
|
||||
if err != nil {
|
||||
t.Fatalf("对真服务合成失败: %v", err)
|
||||
t.Fatalf("对真服务合成失败(character=%s): %v", character, err)
|
||||
}
|
||||
elapsed := time.Since(start)
|
||||
if audio.Format != "wav" {
|
||||
@@ -34,7 +78,5 @@ func TestLiveSpeech(t *testing.T) {
|
||||
if string(audio.Data[:4]) != "RIFF" || string(audio.Data[8:12]) != "WAVE" {
|
||||
t.Fatalf("不是 RIFF/WAVE: %q", string(audio.Data[:12]))
|
||||
}
|
||||
// 证据:真实字节数与耗时(塔要求的"实际字节")。
|
||||
t.Logf("evidence: base=%s character=%s bytes=%d format=%s elapsed=%s",
|
||||
client.Config().BaseURL, client.Character(), len(audio.Data), audio.Format, elapsed)
|
||||
return audio, elapsed
|
||||
}
|
||||
@@ -6,6 +6,9 @@ package tts
|
||||
// (`multimodal/voice.New` 组装的各级列表不外露,插进去要先改那条链,超出本切片
|
||||
// 范围)。提供这个适配是为了让"作为一级"这条路**现在就是通的**:一旦组装方式
|
||||
// 改成显式列级(`voice.NewChain(...)`),本级可以原样进链,不必重写 HTTP。
|
||||
//
|
||||
// 作为链的一级时**选角同样按 ctx 里的 personaId 现算**(`Config.Resolve`),
|
||||
// 没有"这一级的固定发音人"这种概念 —— 因此它也不会把角色定死。
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -34,12 +37,16 @@ func (p *Provider) TTS() mvoice.TTS { return ttsAdapter{p.client} }
|
||||
// ttsAdapter 把 `Client.Speech` 适配成链要求的 `TTS` 形状(`Audio` 类型不同)。
|
||||
type ttsAdapter struct{ client *Client }
|
||||
|
||||
// TTS 合成音频;client 未接线时返回明确错误而不是 panic。
|
||||
// TTS 按 ctx 里的 personaId 选发音人后合成;client 未接线时返回明确错误而不是 panic。
|
||||
func (a ttsAdapter) TTS(ctx context.Context, text string) (mvoice.Audio, error) {
|
||||
if a.client == nil {
|
||||
return mvoice.Audio{}, mvoice.ErrNotConfigured
|
||||
}
|
||||
audio, err := a.client.Speech(ctx, text)
|
||||
selection, err := a.client.Config().Resolve(PersonaIDFromContext(ctx))
|
||||
if err != nil {
|
||||
return mvoice.Audio{}, err
|
||||
}
|
||||
audio, err := a.client.Speech(ctx, selection.Character, text)
|
||||
if err != nil {
|
||||
return mvoice.Audio{}, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package tts
|
||||
|
||||
// 本文件是**发音人的选角**:把「本次请求属于哪个角色(personaId)」映射成
|
||||
// 「上游要用的 characterId」。它是主人 2026-10-26 那条硬要求的落点:
|
||||
//
|
||||
// 「TTS 的人物选择是要绑定后端的角色的,不然会出现以后换了别的角色(人格配置),
|
||||
// 还是用老角色的 TTS 角色」
|
||||
//
|
||||
// 三条设计约束(违反其一就是那条病害复发):
|
||||
//
|
||||
// 1. **单一真源**:映射只来自 `Config.Personas`(面板文件 voice-tts.json)。
|
||||
// `configs/personas/*.yaml` **不参与** —— 那是启动期静态配置,面板是运行期
|
||||
// 可热调,两处必然漂移。
|
||||
// 2. **不许定死**:代码里没有任何兜底 character(`DefaultCharacter` 只是文档示例)。
|
||||
// 3. 🔴 **映射缺失时绝不借用别人的声音**:`Default` 只服务「personaId 解析不到」
|
||||
// (空串),**不服务**「配了 Personas 但没有这个 persona 的键」——后者一律
|
||||
// 拒绝合成,让文本照发。用 default 去顶就是"换了人格还用老角色的声音"。
|
||||
//
|
||||
// 选角是**每次调用现算**的纯函数(读 ctx 里的 personaId + 配置),没有任何跨请求
|
||||
// 缓存 —— 因此同一进程内先后服务两个 persona,第二次绝不会沿用第一次的 voice
|
||||
//(`selection_test.go` 的 `TestResolveIsNotCachedAcrossPersonas` 钉这一条)。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Selection 是本次合成选中的发音人与它的**来源**(来源要进日志:出问题时能一眼
|
||||
// 看出"这次是按哪条规则选的")。
|
||||
type Selection struct {
|
||||
// PersonaID 是本次请求所属角色(空 = 解析不到)。
|
||||
PersonaID string
|
||||
// Character 是映射到的上游角色 id(可能带 `char_` 前缀,按原样发出)。
|
||||
Character string
|
||||
// Source 是选角来源:mapped(persona 映射命中)| default(解析不到 persona,
|
||||
// 用了配置里的 default)。
|
||||
Source SelectionSource
|
||||
}
|
||||
|
||||
// SelectionSource 是选角来源的闭集。
|
||||
type SelectionSource string
|
||||
|
||||
const (
|
||||
// SourceMapped 表示命中 `Config.Personas` 里该 persona 的映射。
|
||||
SourceMapped SelectionSource = "mapped"
|
||||
// SourceDefault 表示 personaId 解析不到,用了 `Config.Default`。
|
||||
SourceDefault SelectionSource = "default"
|
||||
)
|
||||
|
||||
// 选角失败的原因(可分类 —— 面板/日志据此说清"为什么这次没声音")。
|
||||
var (
|
||||
// ErrNoCharacter 报告「解析不到 personaId,且没有配 default」⇒ 不合成。
|
||||
ErrNoCharacter = errors.New("voice/tts: no character for this request")
|
||||
// ErrPersonaUnmapped 报告「这个 persona 没有映射」。它**绝不**回落到 default:
|
||||
// 那会让换了人格的她继续用别的角色的声音(主人点名的病害)。
|
||||
ErrPersonaUnmapped = errors.New("voice/tts: persona has no character mapping")
|
||||
)
|
||||
|
||||
// Resolve 按 personaID 选出本次要用的 character。
|
||||
//
|
||||
// 四条分支(与主人的验收清单逐条对应):
|
||||
//
|
||||
// personaID 有映射 ⇒ (Selection{mapped}, nil)
|
||||
// personaID 为空/解析不到 + 配了 Default ⇒ (Selection{default}, nil)
|
||||
// personaID 为空/解析不到 + 没配 Default ⇒ ("", ErrNoCharacter)
|
||||
// personaID 非空 + 没有映射(含映射值为空) ⇒ ("", ErrPersonaUnmapped)
|
||||
//
|
||||
// 🔴 第四行**不会**看 Default —— 这正是"不许借用另一个角色"的编码位置。
|
||||
func (c Config) Resolve(personaID string) (Selection, error) {
|
||||
trimmed := strings.TrimSpace(personaID)
|
||||
if trimmed == "" {
|
||||
if c.Default == "" {
|
||||
return Selection{}, ErrNoCharacter
|
||||
}
|
||||
return Selection{Character: c.Default, Source: SourceDefault}, nil
|
||||
}
|
||||
character := strings.TrimSpace(c.Personas[trimmed])
|
||||
if character == "" {
|
||||
return Selection{PersonaID: trimmed}, ErrPersonaUnmapped
|
||||
}
|
||||
return Selection{PersonaID: trimmed, Character: character, Source: SourceMapped}, nil
|
||||
}
|
||||
|
||||
// personaKey 是 ctx 里携带 personaId 的键(每次请求现传,绝不缓存成全局)。
|
||||
type personaKey struct{}
|
||||
|
||||
// WithPersonaID 返回携带本次请求所属角色 id 的 ctx。空串是合法的(= 解析不到),
|
||||
// 与"没设置"同义 —— 两者都走 default 分支。
|
||||
//
|
||||
// 调用方是**知道会话**的那一层(网关的两条出站口):它先按当次请求解析出
|
||||
// `chat_sessions.persona_id`,再交给语音面。语音面自己不去查库,因此这里没有
|
||||
// 第二个"会话 → 角色"的解析器(design/22 §4.5:一个解析器)。
|
||||
func WithPersonaID(ctx context.Context, personaID string) context.Context {
|
||||
return context.WithValue(ctx, personaKey{}, strings.TrimSpace(personaID))
|
||||
}
|
||||
|
||||
// PersonaIDFromContext 返回 ctx 里携带的角色 id(未设置时为 "")。
|
||||
func PersonaIDFromContext(ctx context.Context) string {
|
||||
if ctx == nil {
|
||||
return ""
|
||||
}
|
||||
if v, ok := ctx.Value(personaKey{}).(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package tts
|
||||
|
||||
// 本文件锁定**选角**的四条分支(主人 2026-10-26 的验收清单逐条对应)。
|
||||
//
|
||||
// 最要紧的是 `TestResolveUnmappedPersonaRefuses`:映射缺失时**绝不**拿 default
|
||||
// 或别的角色去顶 —— 那正是主人担心的病害(「换了别的角色,还是用老角色的 TTS
|
||||
// 角色」)。把这条改成"回落 default"必然打红。
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// selectionCfg 是两份 persona 各映射到**不同** character 的配置。
|
||||
func selectionCfg() Config {
|
||||
return Config{
|
||||
Enabled: true,
|
||||
Personas: map[string]string{
|
||||
"cyrene": "char_84fb88a3",
|
||||
"furina": "char_5314489e",
|
||||
"nameless": "",
|
||||
},
|
||||
Default: "char_fallback",
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveMapsPersonaToCharacter 分支①:有映射 ⇒ 用映射到的角色(两个 persona
|
||||
// 各自映射到不同角色,绝不互相串)。
|
||||
func TestResolveMapsPersonaToCharacter(t *testing.T) {
|
||||
cfg := selectionCfg().Normalized(discardLogger())
|
||||
|
||||
got, err := cfg.Resolve("cyrene")
|
||||
if err != nil || got.Character != "char_84fb88a3" || got.Source != SourceMapped {
|
||||
t.Fatalf("cyrene ⇒ %+v err=%v", got, err)
|
||||
}
|
||||
other, err := cfg.Resolve("furina")
|
||||
if err != nil || other.Character != "char_5314489e" || other.Source != SourceMapped {
|
||||
t.Fatalf("furina ⇒ %+v err=%v", other, err)
|
||||
}
|
||||
if got.Character == other.Character {
|
||||
t.Fatal("两个 persona 拿到了同一个 character(映射被串了)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveUnmappedPersonaRefuses 分支④(红线):配了 personas 但**没有这个
|
||||
// persona 的键** ⇒ 拒绝,**不看 default**、也不借用任何别的角色。
|
||||
func TestResolveUnmappedPersonaRefuses(t *testing.T) {
|
||||
cfg := selectionCfg().Normalized(discardLogger())
|
||||
|
||||
got, err := cfg.Resolve("stranger")
|
||||
if !errors.Is(err, ErrPersonaUnmapped) {
|
||||
t.Fatalf("未映射 persona err = %v, want ErrPersonaUnmapped", err)
|
||||
}
|
||||
if got.Character != "" {
|
||||
t.Fatalf("未映射 persona 却选出了角色 %q —— 这条就是病害本身", got.Character)
|
||||
}
|
||||
// 键在但值为空:同样拒绝(不是"回落 default")。
|
||||
if got, err := cfg.Resolve("nameless"); !errors.Is(err, ErrPersonaUnmapped) || got.Character != "" {
|
||||
t.Fatalf("映射值为空 ⇒ %+v err=%v, want 拒绝", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveUnresolvedPersonaUsesDefault 分支②:personaId 解析不到(空串)⇒ 用
|
||||
// default(配了才用)。
|
||||
func TestResolveUnresolvedPersonaUsesDefault(t *testing.T) {
|
||||
cfg := selectionCfg().Normalized(discardLogger())
|
||||
got, err := cfg.Resolve("")
|
||||
if err != nil || got.Character != "char_fallback" || got.Source != SourceDefault {
|
||||
t.Fatalf("空 persona ⇒ %+v err=%v, want default", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveUnresolvedPersonaWithoutDefaultRefuses 分支②的另一半:没配 default ⇒
|
||||
// 不合成(可分类错误),绝不自己猜一个角色。
|
||||
func TestResolveUnresolvedPersonaWithoutDefaultRefuses(t *testing.T) {
|
||||
cfg := Config{Enabled: true, Personas: map[string]string{"cyrene": "char_84fb88a3"}}.Normalized(discardLogger())
|
||||
if got, err := cfg.Resolve(""); !errors.Is(err, ErrNoCharacter) || got.Character != "" {
|
||||
t.Fatalf("空 persona + 无 default ⇒ %+v err=%v, want ErrNoCharacter", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveIsNotCachedAcrossPersonas 是主人担心那件事的**回归锁**:选角是纯函数,
|
||||
// 同一份配置先后解析两个 persona,第二次绝不沿用第一次的结果。
|
||||
func TestResolveIsNotCachedAcrossPersonas(t *testing.T) {
|
||||
cfg := selectionCfg().Normalized(discardLogger())
|
||||
first, err := cfg.Resolve("cyrene")
|
||||
if err != nil {
|
||||
t.Fatalf("first: %v", err)
|
||||
}
|
||||
second, err := cfg.Resolve("furina")
|
||||
if err != nil {
|
||||
t.Fatalf("second: %v", err)
|
||||
}
|
||||
if second.Character != "char_5314489e" {
|
||||
t.Fatalf("第二次沿用了第一次的 voice(%q)—— 正是主人担心的病害", second.Character)
|
||||
}
|
||||
if first.Character != "char_84fb88a3" {
|
||||
t.Fatalf("第一次的结果被第二次改写了: %q", first.Character)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPersonaContextRoundTrip 钉住 ctx 传递(语音面自己不查库,只读这一处)。
|
||||
func TestPersonaContextRoundTrip(t *testing.T) {
|
||||
if got := PersonaIDFromContext(WithPersonaID(t.Context(), " cyrene ")); got != "cyrene" {
|
||||
t.Fatalf("ctx persona = %q, want cyrene", got)
|
||||
}
|
||||
if got := PersonaIDFromContext(t.Context()); got != "" {
|
||||
t.Fatalf("未设置时应为空, got %q", got)
|
||||
}
|
||||
// nil ctx 不得 panic(防御性)。
|
||||
if got := PersonaIDFromContext(nil); got != "" {
|
||||
t.Fatalf("nil ctx = %q, want 空", got)
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,10 @@ package tts
|
||||
// 🔴 **文本优先**:本层**永不**把 TTS 的失败上抛成"这条回复失败"。失败只做两件事:
|
||||
// 记一条带来源的 WARN + 计数(`Client.Stats`),然后回落既有链;既有链再失败才算
|
||||
// 整体失败,而那时聊天正文早已发出/落库(TTS 是呈现层的增强,不是回复的前置条件)。
|
||||
//
|
||||
// 🔴 **发音人按当次请求的 persona 现算**(主人 2026-10-26 的硬要求):每次
|
||||
// `TTSWithProvider` 都从 ctx 取 personaId、经 `Config.Resolve` 得出 character,
|
||||
// **没有任何跨请求缓存**。因此"换人格后用老角色的声音"不可能发生。
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -79,14 +83,6 @@ func NewService(primary *Client, base Chain, opts ...ServiceOption) *Service {
|
||||
// PrimaryActive 报告 emotionTTS 这一级是否真的在生效(面板据此如实显示"已接")。
|
||||
func (s *Service) PrimaryActive() bool { return s != nil && s.primary != nil && s.primary.Enabled() }
|
||||
|
||||
// Character 返回生效角色(面板展示用)。
|
||||
func (s *Service) Character() string {
|
||||
if s == nil || s.primary == nil {
|
||||
return ""
|
||||
}
|
||||
return s.primary.Character()
|
||||
}
|
||||
|
||||
// Stats 返回 emotionTTS 这一级的计数(尝试/失败);未接线时为 0 值。
|
||||
func (s *Service) Stats() Stats {
|
||||
if s == nil || s.primary == nil {
|
||||
@@ -125,22 +121,35 @@ func (s *Service) TTS(ctx context.Context, text string) (mvoice.Audio, error) {
|
||||
return audio, err
|
||||
}
|
||||
|
||||
// TTSWithProvider 先试 emotionTTS,失败/未启用则回落既有链,并如实报告实际
|
||||
// 服务的那一级(`emotiontts` 或既有链的 cloud/local/...)。
|
||||
// TTSWithProvider 按**本次请求的角色**选发音人并合成;失败/未启用则回落既有链,
|
||||
// 并如实报告实际服务的那一级(`emotiontts` 或既有链的 cloud/local/...)。
|
||||
//
|
||||
// 三条纪律:
|
||||
// - **失败绝不上抛成回复失败**:先回落,回落也失败才把后者的错误交给调用方
|
||||
// (既有行为逐字不变);
|
||||
// - **WARN 带来源**:哪一级、什么原因,一条说清(正文绝不进日志,只有长度);
|
||||
// - **未启用不是故障**:只记 DEBUG,不刷 WARN。
|
||||
// 分支(与 `Config.Resolve` 一一对应,验收清单见 selection.go):
|
||||
// - 选到发音人 ⇒ 用它合成;上游失败 ⇒ 回落既有链 + WARN(口径不变);
|
||||
// - **选不到发音人**(persona 无映射 / 解析不到且没配 default)⇒ **不合成、
|
||||
// 也不回落既有链**,直接回一个可分类错误 + 一条 WARN。回落会让她用**另一个
|
||||
// 声音**说话,正是主人点名的病害;文本早已发出,因此这里"没有声音"是安全结局。
|
||||
func (s *Service) TTSWithProvider(ctx context.Context, text string) (mvoice.Audio, string, error) {
|
||||
if s.PrimaryActive() {
|
||||
audio, err := s.primary.Speech(ctx, text)
|
||||
if err == nil {
|
||||
return mvoice.Audio{Format: audio.Format, Data: audio.Data}, ProviderName, nil
|
||||
}
|
||||
s.degrade(ctx, text, err)
|
||||
if !s.PrimaryActive() {
|
||||
return s.fallback(ctx, text)
|
||||
}
|
||||
selection, err := s.primary.Config().Resolve(PersonaIDFromContext(ctx))
|
||||
if err != nil {
|
||||
// 🔴 绝不借用别的角色的声音:不合成、不回落,如实说清原因。
|
||||
s.log.Warn("voice tts skipped: no character for this persona",
|
||||
"provider", ProviderName, "persona", PersonaIDFromContext(ctx), "text_len", len(text), "err", err)
|
||||
return mvoice.Audio{}, "", err
|
||||
}
|
||||
audio, err := s.primary.Speech(ctx, selection.Character, text)
|
||||
if err == nil {
|
||||
return mvoice.Audio{Format: audio.Format, Data: audio.Data}, ProviderName, nil
|
||||
}
|
||||
s.degrade(ctx, selection, text, err)
|
||||
return s.fallback(ctx, text)
|
||||
}
|
||||
|
||||
// fallback 走既有链;没有底链时回一个明确的错误(不 panic)。
|
||||
func (s *Service) fallback(ctx context.Context, text string) (mvoice.Audio, string, error) {
|
||||
if s.base == nil {
|
||||
return mvoice.Audio{}, "", mvoice.ErrProviderFailed
|
||||
}
|
||||
@@ -149,7 +158,7 @@ func (s *Service) TTSWithProvider(ctx context.Context, text string) (mvoice.Audi
|
||||
|
||||
// degrade 记录一次 emotionTTS 降级:未启用是 DEBUG(正常状态),其余是 WARN
|
||||
// (异常状态,但已被回落吸收)。日志只带文本长度,**绝不带正文**。
|
||||
func (s *Service) degrade(ctx context.Context, text string, err error) {
|
||||
func (s *Service) degrade(ctx context.Context, sel Selection, text string, err error) {
|
||||
if ctx.Err() != nil {
|
||||
// 回合被取消/超时的情形已由 Client 记过(unreachable),这里只补一句
|
||||
// 「因此这次没声音」,避免同一条失败刷两条 WARN。
|
||||
@@ -157,5 +166,7 @@ func (s *Service) degrade(ctx context.Context, text string, err error) {
|
||||
return
|
||||
}
|
||||
s.log.Warn("voice tts degraded to fallback chain",
|
||||
"provider", ProviderName, "text_len", len(text), "err", err)
|
||||
"provider", ProviderName, "persona", sel.PersonaID,
|
||||
"character", sel.Character, "source", string(sel.Source),
|
||||
"text_len", len(text), "err", err)
|
||||
}
|
||||
@@ -1,17 +1,27 @@
|
||||
package tts
|
||||
|
||||
// 本文件锁定 `Service`(emotionTTS + 既有链的门面)的三条口径:
|
||||
// 本文件锁定 `Service`(emotionTTS + 既有链的门面)的口径。**断言的落点是
|
||||
// 「发往上游的请求体里的 voice」**,不是"调了合成" —— 前者才能证明"换了人格就换了
|
||||
// 声音",后者只能证明"发生过一次 HTTP"。
|
||||
//
|
||||
// 1. **健康时用 emotionTTS**,并如实报告级别名 `emotiontts`;
|
||||
// 2. **失败时回落既有链**,且**不把失败上抛**(文本优先:合成挂掉不影响回复);
|
||||
// 3. **未启用时完全透传**(行为与引入本切片之前逐字相同),STT/克隆永远走既有链。
|
||||
// 覆盖:
|
||||
// 1. 发音人跟着 persona 走(两个 persona ⇒ 两次请求体里的 voice 不同);
|
||||
// 2. 🔴 同一进程先后用两个 persona,第二次绝不沿用第一次的 voice(回归锁,
|
||||
// 直接对着主人担心的事);
|
||||
// 3. persona 无映射且没配 default ⇒ **不合成**,且**上游请求数为 0**;
|
||||
// 4. persona 解析不到且配了 default ⇒ 用 default;
|
||||
// 5. 有映射但上游拒绝 ⇒ 沿用既有降级(回落既有链,口径不变);
|
||||
// 6. 未启用 ⇒ 完全透传既有链;STT/克隆永远走既有链。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
|
||||
@@ -22,6 +32,77 @@ func discardLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
// recorder 是一台假 emotionTTS:**记下每次请求体里的 voice**,并按它回一段音频
|
||||
// (音频里嵌着 voice,便于端到端断言"帧里的声音是谁的")。
|
||||
type recorder struct {
|
||||
mu sync.Mutex
|
||||
voices []string
|
||||
status int
|
||||
wav bool
|
||||
}
|
||||
|
||||
// handler 是 recorder 的 HTTP 处理器。
|
||||
func (r *recorder) handler(t *testing.T) http.HandlerFunc {
|
||||
t.Helper()
|
||||
return func(w http.ResponseWriter, req *http.Request) {
|
||||
var payload speechPayload
|
||||
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
|
||||
t.Errorf("请求体不是合法 JSON: %v", err)
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.voices = append(r.voices, payload.Voice)
|
||||
status, wantWAV := r.status, r.wav
|
||||
r.mu.Unlock()
|
||||
if status != 0 && status != http.StatusOK {
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write([]byte(`{"detail":"upstream says no"}`))
|
||||
return
|
||||
}
|
||||
if !wantWAV {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"status":"success"}`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(wavBytes("voice=" + payload.Voice))
|
||||
}
|
||||
}
|
||||
|
||||
// VoiceLog 返回**按顺序**记下的 voice 列表。
|
||||
func (r *recorder) VoiceLog() []string {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := make([]string, len(r.voices))
|
||||
copy(out, r.voices)
|
||||
return out
|
||||
}
|
||||
|
||||
// Calls 返回上游被调用次数。
|
||||
func (r *recorder) Calls() int {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return len(r.voices)
|
||||
}
|
||||
|
||||
// newRecordingService 造一个指向 recorder 的 Service。
|
||||
func newRecordingService(t *testing.T, cfg Config, base Chain) (*Service, *recorder) {
|
||||
t.Helper()
|
||||
rec := &recorder{wav: true}
|
||||
srv := httptest.NewServer(rec.handler(t))
|
||||
t.Cleanup(srv.Close)
|
||||
cfg.Enabled = true
|
||||
cfg.BaseURL = srv.URL
|
||||
client := New(cfg, WithLogger(discardLogger()))
|
||||
return NewService(client, base, WithServiceLogger(discardLogger())), rec
|
||||
}
|
||||
|
||||
// personaCfg 是两份 persona ⇒ 两个**不同** character 的配置。
|
||||
func personaCfg() Config {
|
||||
return Config{Personas: map[string]string{
|
||||
"cyrene": "char_84fb88a3",
|
||||
"furina": "char_5314489e",
|
||||
}}
|
||||
}
|
||||
|
||||
// fakeChain 是一个可编程的底链替身,记录自己被调了几次。
|
||||
type fakeChain struct {
|
||||
ttsCalls int
|
||||
@@ -56,54 +137,115 @@ func (f *fakeChain) CloneVoice(context.Context, string, string) (string, error)
|
||||
return "voice-id", nil
|
||||
}
|
||||
|
||||
// TestServiceUsesPrimaryWhenHealthy 钉住主路:健康时走 emotionTTS 且报告级别名。
|
||||
func TestServiceUsesPrimaryWhenHealthy(t *testing.T) {
|
||||
client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write(wavBytes("her voice"))
|
||||
})
|
||||
// TestServiceVoiceFollowsPersona 分支①:两次请求、两个 persona ⇒ 请求体里的
|
||||
// voice 分别是两个不同的 character。
|
||||
func TestServiceVoiceFollowsPersona(t *testing.T) {
|
||||
base := &fakeChain{audio: mvoice.Audio{Format: "mp3", Data: []byte("fallback")}}
|
||||
svc := NewService(client, base, WithServiceLogger(discardLogger()))
|
||||
svc, rec := newRecordingService(t, personaCfg(), base)
|
||||
|
||||
audio, rung, err := svc.TTSWithProvider(context.Background(), chineseSpeech)
|
||||
if err != nil {
|
||||
t.Fatalf("TTSWithProvider: %v", err)
|
||||
for _, persona := range []string{"cyrene", "furina"} {
|
||||
ctx := WithPersonaID(context.Background(), persona)
|
||||
if _, rung, err := svc.TTSWithProvider(ctx, chineseSpeech); err != nil || rung != ProviderName {
|
||||
t.Fatalf("%s: rung=%q err=%v", persona, rung, err)
|
||||
}
|
||||
}
|
||||
if rung != ProviderName {
|
||||
t.Errorf("rung = %q, want %q", rung, ProviderName)
|
||||
}
|
||||
if audio.Format != "wav" || string(audio.Data) == "fallback" {
|
||||
t.Errorf("audio = %+v, want the emotionTTS bytes", audio)
|
||||
want := []string{"char_84fb88a3", "char_5314489e"}
|
||||
got := rec.VoiceLog()
|
||||
if len(got) != 2 || got[0] != want[0] || got[1] != want[1] {
|
||||
t.Fatalf("请求体里的 voice = %v, want %v", got, want)
|
||||
}
|
||||
if base.ttsCalls != 0 {
|
||||
t.Errorf("主路健康却回落了底链 %d 次", base.ttsCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestServiceFallsBackWhenPrimaryFails 钉住降级:主路失败 ⇒ 底链出声音,
|
||||
// **错误不上抛**(文本优先)。
|
||||
func TestServiceFallsBackWhenPrimaryFails(t *testing.T) {
|
||||
client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"detail":"indexTTS2 down"}`))
|
||||
})
|
||||
base := &fakeChain{audio: mvoice.Audio{Format: "mp3", Data: []byte("fallback")}}
|
||||
svc := NewService(client, base, WithServiceLogger(discardLogger()))
|
||||
// TestServiceSequentialPersonasDoNotReuseVoice 是主人担心那件事的**回归锁**:
|
||||
// 同一进程先服务 cyrene 再服务 furina,第二次请求体里的 voice 必须是 furina 的,
|
||||
// **绝不**沿用第一次。
|
||||
func TestServiceSequentialPersonasDoNotReuseVoice(t *testing.T) {
|
||||
svc, rec := newRecordingService(t, personaCfg(), &fakeChain{})
|
||||
ctx := context.Background()
|
||||
|
||||
audio, rung, err := svc.TTSWithProvider(context.Background(), chineseSpeech)
|
||||
if _, _, err := svc.TTSWithProvider(WithPersonaID(ctx, "cyrene"), "第一句"); err != nil {
|
||||
t.Fatalf("first: %v", err)
|
||||
}
|
||||
if _, _, err := svc.TTSWithProvider(WithPersonaID(ctx, "furina"), "第二句"); err != nil {
|
||||
t.Fatalf("second: %v", err)
|
||||
}
|
||||
got := rec.VoiceLog()
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("上游被调用 %d 次, want 2", len(got))
|
||||
}
|
||||
if got[1] != "char_5314489e" {
|
||||
t.Fatalf("第二次请求体里的 voice = %q —— 沿用了第一次的角色(病害复发)", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestServiceUnmappedPersonaSkipsSynthesis 分支④(红线):persona 无映射、且**没配**
|
||||
// default ⇒ 不合成:上游请求数为 0,底链也**不许**顶替(那会用另一个声音)。
|
||||
func TestServiceUnmappedPersonaSkipsSynthesis(t *testing.T) {
|
||||
base := &fakeChain{audio: mvoice.Audio{Format: "wav", Data: []byte("fallback")}}
|
||||
svc, rec := newRecordingService(t, personaCfg(), base)
|
||||
|
||||
_, _, err := svc.TTSWithProvider(WithPersonaID(context.Background(), "stranger"), chineseSpeech)
|
||||
if !errors.Is(err, ErrPersonaUnmapped) {
|
||||
t.Fatalf("err = %v, want ErrPersonaUnmapped", err)
|
||||
}
|
||||
if rec.Calls() != 0 {
|
||||
t.Fatalf("上游请求数 = %d, want 0(这条 persona 没有发音人,不该发任何合成请求)", rec.Calls())
|
||||
}
|
||||
if base.ttsCalls != 0 {
|
||||
t.Fatalf("回落到了既有链 %d 次 —— 那会用另一个角色的声音说话", base.ttsCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestServiceUnresolvedPersonaUsesDefault 分支②:解析不到 personaId(ctx 没设)且
|
||||
// 配了 default ⇒ 请求体里的 voice 是 default。与上一条互为对照。
|
||||
func TestServiceUnresolvedPersonaUsesDefault(t *testing.T) {
|
||||
cfg := personaCfg()
|
||||
cfg.Default = "char_default_voice"
|
||||
svc, rec := newRecordingService(t, cfg, &fakeChain{})
|
||||
|
||||
if _, _, err := svc.TTSWithProvider(context.Background(), chineseSpeech); err != nil {
|
||||
t.Fatalf("TTSWithProvider: %v", err)
|
||||
}
|
||||
got := rec.VoiceLog()
|
||||
if len(got) != 1 || got[0] != "char_default_voice" {
|
||||
t.Fatalf("请求体里的 voice = %v, want [char_default_voice]", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestServiceUnresolvedPersonaWithoutDefaultSkips 分支②的另一半:没配 default ⇒
|
||||
// 不合成(请求数 0),与"用 default"严格区分。
|
||||
func TestServiceUnresolvedPersonaWithoutDefaultSkips(t *testing.T) {
|
||||
svc, rec := newRecordingService(t, personaCfg(), &fakeChain{})
|
||||
_, _, err := svc.TTSWithProvider(context.Background(), chineseSpeech)
|
||||
if !errors.Is(err, ErrNoCharacter) {
|
||||
t.Fatalf("err = %v, want ErrNoCharacter", err)
|
||||
}
|
||||
if rec.Calls() != 0 {
|
||||
t.Fatalf("上游请求数 = %d, want 0", rec.Calls())
|
||||
}
|
||||
}
|
||||
|
||||
// TestServiceFallsBackWhenUpstreamRejects 分支③:有映射但上游拒绝(500)⇒ 沿用既有
|
||||
// 降级(回落既有链 + WARN),且**先按正确角色试过**(请求体里的 voice 是映射到的那个)。
|
||||
func TestServiceFallsBackWhenUpstreamRejects(t *testing.T) {
|
||||
base := &fakeChain{audio: mvoice.Audio{Format: "mp3", Data: []byte("fallback")}}
|
||||
svc, rec := newRecordingService(t, personaCfg(), base)
|
||||
rec.mu.Lock()
|
||||
rec.status = http.StatusInternalServerError
|
||||
rec.mu.Unlock()
|
||||
|
||||
audio, rung, err := svc.TTSWithProvider(WithPersonaID(context.Background(), "furina"), chineseSpeech)
|
||||
if err != nil {
|
||||
t.Fatalf("主路失败不该让合成整体失败(文本优先): %v", err)
|
||||
}
|
||||
if rung != "silent" {
|
||||
t.Errorf("rung = %q, want 底链报告的那一级", rung)
|
||||
if rung != "silent" || string(audio.Data) != "fallback" || base.ttsCalls != 1 {
|
||||
t.Fatalf("没有按既有口径回落: rung=%q audio=%q calls=%d", rung, audio.Data, base.ttsCalls)
|
||||
}
|
||||
if string(audio.Data) != "fallback" {
|
||||
t.Errorf("audio = %+v, want 底链产物", audio)
|
||||
}
|
||||
if base.ttsCalls != 1 {
|
||||
t.Errorf("底链被调用 %d 次, want 1", base.ttsCalls)
|
||||
}
|
||||
if got := svc.Stats(); got.Failures != 1 {
|
||||
t.Errorf("降级没有计数: %+v", got)
|
||||
if got := rec.VoiceLog(); len(got) != 1 || got[0] != "char_5314489e" {
|
||||
t.Fatalf("失败那次用的 voice = %v, want [char_5314489e](应先按正确角色试)", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,18 +301,32 @@ func TestServiceDelegatesSTTAndClone(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestProviderAdapter 钉住"作为链的一级"这条路现在就是通的(STT 方向为 nil)。
|
||||
func TestProviderAdapter(t *testing.T) {
|
||||
client := New(Config{Enabled: true, BaseURL: "http://127.0.0.1:1"}, WithLogger(discardLogger()))
|
||||
p := NewProvider(client)
|
||||
// TestProviderAdapterSelectsByPersona 钉住"作为链的一级"这条路:选角同样按 ctx 的
|
||||
// personaId 现算(没有"这一级的固定发音人")。
|
||||
func TestProviderAdapterSelectsByPersona(t *testing.T) {
|
||||
rec := &recorder{wav: true}
|
||||
srv := httptest.NewServer(rec.handler(t))
|
||||
t.Cleanup(srv.Close)
|
||||
cfg := personaCfg()
|
||||
cfg.Enabled = true
|
||||
cfg.BaseURL = srv.URL
|
||||
p := NewProvider(New(cfg, WithLogger(discardLogger())))
|
||||
|
||||
if p.Name() != ProviderName {
|
||||
t.Errorf("Name = %q, want %q", p.Name(), ProviderName)
|
||||
}
|
||||
if p.STT() != nil {
|
||||
t.Error("emotionTTS 不提供转写,STT 应为 nil")
|
||||
}
|
||||
if _, err := p.TTS().TTS(context.Background(), " "); !errors.Is(err, ErrEmptyText) {
|
||||
t.Errorf("err = %v, want ErrEmptyText(穿透到 Client 的守卫)", err)
|
||||
if _, err := p.TTS().TTS(WithPersonaID(context.Background(), "furina"), "你好"); err != nil {
|
||||
t.Fatalf("TTS: %v", err)
|
||||
}
|
||||
if got := rec.VoiceLog(); len(got) != 1 || got[0] != "char_5314489e" {
|
||||
t.Fatalf("请求体里的 voice = %v, want [char_5314489e]", got)
|
||||
}
|
||||
// 未映射的 persona ⇒ 明确失败,绝不用默认角色凑一个。
|
||||
if _, err := p.TTS().TTS(WithPersonaID(context.Background(), "stranger"), "你好"); !errors.Is(err, ErrPersonaUnmapped) {
|
||||
t.Fatalf("err = %v, want ErrPersonaUnmapped", err)
|
||||
}
|
||||
if _, err := NewProvider(nil).TTS().TTS(context.Background(), "hi"); !errors.Is(err, mvoice.ErrNotConfigured) {
|
||||
t.Errorf("未接线 err = %v, want ErrNotConfigured", err)
|
||||
|
||||
Reference in new issue
Block a user