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
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
|
||||
voicetts "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/tts"
|
||||
)
|
||||
|
||||
// maxAudioRequestBytes 限定 /api/v1/audio/* 请求可携带的 JSON 体大小,
|
||||
@@ -27,11 +28,17 @@ type transcribeRequest struct {
|
||||
// VOICE_TTS_VOICE_ID / VOICE_TTS_FORMAT 配置。Instruction 是可选的
|
||||
// 每请求情绪/语气覆盖,用于克隆音色(声音复刻);系统音色
|
||||
// (longxiaochun_v3)忽略它,绝不接收自由形式的指令。
|
||||
//
|
||||
// SessionID 是**可选**的会话标识(切片 M35,纯增量):发音人按
|
||||
// 「该会话绑定的人格」解析(`chat_sessions.persona_id`)。省略它时本口没有会话
|
||||
// 概念 ⇒ 走 emotionTTS 的 default 分支(没配 default 就不合成)。客户端**绝不**
|
||||
// 通过它选音色:音色由服务端的 persona 映射决定,请求只声明"这是哪个会话"。
|
||||
type speechRequest struct {
|
||||
Text string `json:"text"`
|
||||
Voice string `json:"voice,omitempty"`
|
||||
Format string `json:"format,omitempty"`
|
||||
Instruction string `json:"instruction,omitempty"`
|
||||
SessionID string `json:"sessionId,omitempty"`
|
||||
}
|
||||
|
||||
// handleTranscribe 对上传的音频执行 STT 并返回识别出的
|
||||
@@ -100,6 +107,10 @@ func (s *Server) handleSpeech(w http.ResponseWriter, r *http.Request) {
|
||||
if req.Instruction != "" {
|
||||
ctx = voice.WithTTSInstruction(ctx, req.Instruction)
|
||||
}
|
||||
// 发音人按**本次请求的会话**所绑定的人格解析(切片 M35)。空 sessionId 或
|
||||
// 会话无绑定 ⇒ personaID 为空 ⇒ 语音面走 default 分支(没有 default 就不合成)。
|
||||
// 解析只做一次、只服务本次请求,绝不在注入时算一次并缓存成全局。
|
||||
ctx = voicetts.WithPersonaID(ctx, s.envResolver.sessionPersona(ctx, req.SessionID))
|
||||
audi, rung, err := ttsWithProvider(ctx, s.voice, req.Text)
|
||||
if err != nil {
|
||||
s.auditClaims(r, "audio.speech", "error")
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
|
||||
voicetts "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/tts"
|
||||
)
|
||||
|
||||
// VoiceService 为 voice_input WS 路由运行语音转文本与
|
||||
@@ -177,7 +178,7 @@ func (h *hub) runVoiceTurn(c *wsConn, req voiceInputRequest) {
|
||||
msgs := []model.Message{model.TextMessage(model.RoleUser, transcript)}
|
||||
// 两条「怎么回到这条连接」的通路都由本连接给出:收尾回调(音频回复,C1)与
|
||||
// 定向拒绝回调(队列满,G5)。编排器只把它们当成本回合的传输线索。
|
||||
ctx = withVoiceReplySink(ctx, h.voiceReplySink(c, req.SessionID, req.DeviceID, transcript))
|
||||
ctx = withVoiceReplySink(ctx, h.voiceReplySink(c, req.SessionID, req.DeviceID, env.PersonaID, transcript))
|
||||
ctx = withPendingFullNotifier(ctx, c.notifyPendingFull)
|
||||
if h.orchestrator != nil {
|
||||
h.orchestrator.Submit(ctx, req.SessionID, msgs, env)
|
||||
@@ -196,16 +197,20 @@ func (h *hub) runVoiceTurnDirect(ctx context.Context, c *wsConn, sessionID, devi
|
||||
h.voiceError(c, "voice chat failed")
|
||||
return
|
||||
}
|
||||
h.synthesizeVoiceReply(ctx, c, sessionID, deviceID, transcript, voiceReplyText(res))
|
||||
h.synthesizeVoiceReply(ctx, c, sessionID, deviceID, env.PersonaID, transcript, voiceReplyText(res))
|
||||
}
|
||||
|
||||
// voiceReplySink 返回本次语音请求的收尾回调:回合结算后取助手正文 → TTS →
|
||||
// **定向**回发起它的那根连接。它是 C1 的核心接线点 —— 回合异步完成,只有结算处
|
||||
// 知道助手正文。
|
||||
//
|
||||
// personaID 是**本回合**解析出的角色(`env.PersonaID`,会话无绑定时为空)。它随
|
||||
// 回调一起传下去,使合成按这一回合的角色选发音人 —— 而不是进程启动时定死一个。
|
||||
// 它不是身份输入(身份仍由 actor 元数据决定),只决定用哪个上游音色。
|
||||
//
|
||||
// 连接已断(客户端走了)时记一条带来源的 WARN,绝不静默丢:那正是「静默成功但
|
||||
// 没声音」那类缺陷的温床(协同 C2 的教训)。
|
||||
func (h *hub) voiceReplySink(c *wsConn, sessionID, deviceID, transcript string) VoiceReplyFunc {
|
||||
func (h *hub) voiceReplySink(c *wsConn, sessionID, deviceID, personaID, transcript string) VoiceReplyFunc {
|
||||
return func(res *loop.Result) {
|
||||
if c.closed() {
|
||||
h.voiceLog().Warn("voice reply dropped", "reason", "connection closed", "session", sessionID, "device_id", deviceID)
|
||||
@@ -213,26 +218,31 @@ func (h *hub) voiceReplySink(c *wsConn, sessionID, deviceID, transcript string)
|
||||
}
|
||||
// 回合的 ctx 已随回合消亡,因此合成用全新的后台 context:音频是「回复已
|
||||
// 落库」之后的增强,不该被一次被取消的回合连带取消。
|
||||
h.synthesizeVoiceReply(context.Background(), c, sessionID, deviceID, transcript, voiceReplyText(res))
|
||||
h.synthesizeVoiceReply(context.Background(), c, sessionID, deviceID, personaID, transcript, voiceReplyText(res))
|
||||
}
|
||||
}
|
||||
|
||||
// synthesizeVoiceReply 把助手正文合成音频并定向回发起这条语音的连接
|
||||
// (协同 C1/C3)。三条纪律:
|
||||
// (协同 C1/C3)。四条纪律:
|
||||
// - TTS 失败或降级为空音频时**照旧回帧**并带既有 provider 降级信号,让客户端
|
||||
// 能提示「没生成声音」(绝不是只回一条错误帧就完事);
|
||||
// - 没有助手正文(被取消 / 空回复)时不打一次无谓的 TTS,直接回降级帧 ——
|
||||
// 客户端总能看到这条语音回合的终点,而不是永远等一个不会来的 voice_output;
|
||||
// - **发音人按本回合的 personaID 现解析**(`voicetts.WithPersonaID`):换人格后
|
||||
// 绝不会继续用老角色的声音(主人 2026-10-26 的硬要求);
|
||||
// - 日志只带会话与设备 id,**绝不记正文/音频**。
|
||||
func (h *hub) synthesizeVoiceReply(ctx context.Context, c *wsConn, sessionID, deviceID, transcript, reply string) {
|
||||
func (h *hub) synthesizeVoiceReply(ctx context.Context, c *wsConn, sessionID, deviceID, personaID, transcript, reply string) {
|
||||
if strings.TrimSpace(reply) == "" {
|
||||
h.voiceLog().Warn("voice reply empty", "reason", "turn produced no text", "session", sessionID, "device_id", deviceID)
|
||||
emitVoiceOutput(c, sessionID, deviceID, transcript, "", voice.Audio{}, providerSignal("", true))
|
||||
return
|
||||
}
|
||||
// 把**本回合**的角色交给语音面选发音人;空 personaID = 会话无绑定 ⇒ 语音面
|
||||
// 走它的 default 分支(没配 default 就不合成,文本已发出)。
|
||||
ctx = voicetts.WithPersonaID(ctx, personaID)
|
||||
audi, rung, err := ttsWithProvider(ctx, h.voice, reply)
|
||||
if err != nil {
|
||||
h.voiceLog().Warn("voice tts", "err", err, "session", sessionID, "device_id", deviceID)
|
||||
h.voiceLog().Warn("voice tts", "err", err, "session", sessionID, "device_id", deviceID, "persona", personaID)
|
||||
emitVoiceOutput(c, sessionID, deviceID, transcript, reply, voice.Audio{}, providerSignal("", true))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,145 +1,260 @@
|
||||
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
|
||||
}
|
||||
package gateway
|
||||
|
||||
// 本文件是切片 M35 的**两条既有出站口实测**,并且钉住主人 2026-10-26 的硬要求:
|
||||
// **发音人跟着会话的人格走**(绝不定死一个)。
|
||||
//
|
||||
// 1. `POST /api/v1/audio/speech`:回的是音频字节(不是 JSON 信封/空体),且
|
||||
// 请求体里的 `sessionId` 解析到的人格决定上游 `voice`;
|
||||
// 2. WS `voice_output`:帧里带的是那段音频的 base64,且发音人取**本回合**的
|
||||
// `env.PersonaID`(不是进程启动时算一次)。
|
||||
//
|
||||
// 断言落在「上游请求体里的 voice」与「帧里音频的内容」上 —— 它们才能证明
|
||||
// "换了人格就换了声音"。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"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"
|
||||
)
|
||||
|
||||
// personaCharacters 是两个角色各自的上游 character id(与生产配置同形)。
|
||||
var personaCharacters = map[string]string{
|
||||
"cyrene": "char_84fb88a3",
|
||||
"furina": "char_5314489e",
|
||||
}
|
||||
|
||||
// fakeWAV 造一段魔数合法、且**内容里带着 voice 名**的假 WAV —— 这样端到端断言
|
||||
// 帧/响应里的字节就能直接看出"这段声音是谁的"。
|
||||
func fakeWAV(voice string) []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("voice="+voice)...)
|
||||
}
|
||||
|
||||
// vocalRecorder 是一台假 emotionTTS:记下每次请求体里的 voice。
|
||||
type vocalRecorder struct {
|
||||
mu sync.Mutex
|
||||
voices []string
|
||||
}
|
||||
|
||||
// handler 解码请求体、记录 voice、回一段内容里带 voice 的 WAV。
|
||||
func (r *vocalRecorder) handler(t *testing.T) http.HandlerFunc {
|
||||
t.Helper()
|
||||
return func(w http.ResponseWriter, req *http.Request) {
|
||||
if req.URL.Path != "/v1/audio/speech" {
|
||||
t.Errorf("path = %q, want /v1/audio/speech", req.URL.Path)
|
||||
}
|
||||
var payload struct {
|
||||
Model string `json:"model"`
|
||||
Input string `json:"input"`
|
||||
Voice string `json:"voice"`
|
||||
}
|
||||
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
|
||||
t.Errorf("请求体不是合法 UTF-8 JSON: %v", err)
|
||||
}
|
||||
if payload.Model != "emotionTTS" {
|
||||
t.Errorf("model = %q, want emotionTTS", payload.Model)
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.voices = append(r.voices, payload.Voice)
|
||||
r.mu.Unlock()
|
||||
w.Header().Set("Content-Type", "audio/wav")
|
||||
_, _ = w.Write(fakeWAV(payload.Voice))
|
||||
}
|
||||
}
|
||||
|
||||
// Voices 返回**按顺序**记下的 voice。
|
||||
func (r *vocalRecorder) Voices() []string {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := make([]string, len(r.voices))
|
||||
copy(out, r.voices)
|
||||
return out
|
||||
}
|
||||
|
||||
// emotionTTSService 用假上游构造一个**真的** tts.Service(不是 stub)。
|
||||
//
|
||||
// cfg 由调用方覆盖(personas / default),本函数只补齐 Enabled 与 BaseURL。
|
||||
func emotionTTSService(t *testing.T, rec *vocalRecorder, cfg voicetts.Config, base voicetts.Chain) *voicetts.Service {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(rec.handler(t))
|
||||
t.Cleanup(srv.Close)
|
||||
cfg.Enabled = true
|
||||
cfg.BaseURL = srv.URL
|
||||
return voicetts.NewService(voicetts.New(cfg), base)
|
||||
}
|
||||
|
||||
// personaHistory 是一个**只报一个 persona** 的 history 替身(满足
|
||||
// `sessionPersonaReader`),用来驱动两条口的人格解析。它复用测试包里的
|
||||
// fakeHistory 以满足 HistoryStore 的其余方法。
|
||||
type personaHistory struct {
|
||||
*fakeHistory
|
||||
persona string
|
||||
}
|
||||
|
||||
// SessionPersona 报该 persona(未知会话与读错误对上层都退化成 "",这里只测成功路径)。
|
||||
func (h personaHistory) SessionPersona(context.Context, string) (string, error) {
|
||||
return h.persona, nil
|
||||
}
|
||||
|
||||
// personaResolver 造一个把 sessionID 解析成给定 persona 的解析器。
|
||||
func personaResolver(persona string) turnEnvResolver {
|
||||
return turnEnvResolver{history: personaHistory{fakeHistory: &fakeHistory{}, persona: persona}, log: sourcedLog(nil)}
|
||||
}
|
||||
|
||||
// TestSpeechEndpointVoiceFollowsSessionPersona 是 REST 口的实测:同一个进程、同一台
|
||||
// 服务,**先**用 cyrene 会话、**后**用 furina 会话 ⇒ 两次上游请求体里的 voice 分别是
|
||||
// 两个不同的角色(第二次绝不沿用第一次)。
|
||||
func TestSpeechEndpointVoiceFollowsSessionPersona(t *testing.T) {
|
||||
env := newEnv(t, nil, false)
|
||||
rec := &vocalRecorder{}
|
||||
base := &silentBase{}
|
||||
env.srv.SetVoice(emotionTTSService(t, rec, voicetts.Config{Personas: personaCharacters}, base))
|
||||
token := env.token(t, auth.RoleOwner)
|
||||
|
||||
for _, persona := range []string{"cyrene", "furina"} {
|
||||
env.srv.envResolver = personaResolver(persona)
|
||||
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/audio/speech", map[string]any{
|
||||
"text": "你好,我是苏梦。", "sessionId": "web:" + persona,
|
||||
}, token)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("%s: status = %d, body = %s", persona, resp.StatusCode, body)
|
||||
}
|
||||
if ct := resp.Header.Get("Content-Type"); ct != "audio/wav" {
|
||||
t.Errorf("%s: Content-Type = %q, want audio/wav", persona, ct)
|
||||
}
|
||||
if string(body) != string(fakeWAV(personaCharacters[persona])) {
|
||||
t.Fatalf("%s: 响应体不是该角色的音频: %d bytes", persona, len(body))
|
||||
}
|
||||
}
|
||||
want := []string{personaCharacters["cyrene"], personaCharacters["furina"]}
|
||||
got := rec.Voices()
|
||||
if len(got) != 2 || got[0] != want[0] || got[1] != want[1] {
|
||||
t.Fatalf("请求体里的 voice = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpeechEndpointUnmappedPersonaRefuses 是 REST 口的红线:会话绑的 persona 没有
|
||||
// 映射 ⇒ **不合成**(上游 0 次请求),接口如实报错,绝不借用别的角色。
|
||||
func TestSpeechEndpointUnmappedPersonaRefuses(t *testing.T) {
|
||||
env := newEnv(t, nil, false)
|
||||
rec := &vocalRecorder{}
|
||||
env.srv.SetVoice(emotionTTSService(t, rec, voicetts.Config{
|
||||
Personas: personaCharacters,
|
||||
Default: personaCharacters["cyrene"], // 配了 default 也**不许**顶未映射的 persona
|
||||
}, &silentBase{}))
|
||||
env.srv.envResolver = personaResolver("stranger")
|
||||
|
||||
token := env.token(t, auth.RoleOwner)
|
||||
resp, _ := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/audio/speech", map[string]any{
|
||||
"text": "你好", "sessionId": "web:sub-9",
|
||||
}, token)
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
t.Fatal("未映射 persona 却回了 200 —— 会让她用别人的声音说话")
|
||||
}
|
||||
if got := rec.Voices(); len(got) != 0 {
|
||||
t.Fatalf("上游请求数 = %d, want 0(voice=%v)", len(got), got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpeechEndpointNoSessionUsesDefault 钉住"这条口没有会话时"的诚实处置:
|
||||
// 省略 sessionId ⇒ persona 解析不到 ⇒ 用配置的 default(没配则同样不合成)。
|
||||
func TestSpeechEndpointNoSessionUsesDefault(t *testing.T) {
|
||||
env := newEnv(t, nil, false)
|
||||
rec := &vocalRecorder{}
|
||||
defaultVoice := "char_default_voice"
|
||||
env.srv.SetVoice(emotionTTSService(t, rec, voicetts.Config{
|
||||
Personas: personaCharacters,
|
||||
Default: defaultVoice,
|
||||
}, &silentBase{}))
|
||||
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 got := rec.Voices(); len(got) != 1 || got[0] != defaultVoice {
|
||||
t.Fatalf("请求体里的 voice = %v, want [%s]", got, defaultVoice)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVoiceOutputFrameVoiceFollowsTurnPersona 是 WS 口的实测:发音人取**本回合**的
|
||||
// env.PersonaID。两次回合(两个 persona)⇒ 两次帧里的音频内容分别属于两个角色。
|
||||
func TestVoiceOutputFrameVoiceFollowsTurnPersona(t *testing.T) {
|
||||
for _, persona := range []string{"cyrene", "furina"} {
|
||||
t.Run(persona, func(t *testing.T) {
|
||||
hb := newTestHub(&stubRunner{res: &loop.Result{Text: "你好,我是苏梦。", Steps: 1, StopReason: "stop"}}, nil)
|
||||
hb.envResolver = personaResolver(persona)
|
||||
rec := &vocalRecorder{}
|
||||
hb.voice = emotionTTSService(t, rec, voicetts.Config{Personas: personaCharacters}, &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)
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(asString(data["audio"]))
|
||||
if err != nil || len(raw) == 0 {
|
||||
t.Fatalf("帧里没有可解码的音频(err=%v)", err)
|
||||
}
|
||||
want := fakeWAV(personaCharacters[persona])
|
||||
if string(raw) != string(want) {
|
||||
t.Fatalf("帧里的声音不是 %s 的: %q", persona, string(raw))
|
||||
}
|
||||
if data["reply"] != "你好,我是苏梦。" {
|
||||
t.Errorf("reply = %v, want 助手正文", data["reply"])
|
||||
}
|
||||
if got := rec.Voices(); len(got) != 1 || got[0] != personaCharacters[persona] {
|
||||
t.Fatalf("请求体里的 voice = %v, want [%s]", got, personaCharacters[persona])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// asString 安全地取一个 string 值(帧载荷是 map[string]any)。
|
||||
func asString(v any) string {
|
||||
s, _ := v.(string)
|
||||
return s
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
Reference in new issue
Block a user