Merge branch 'feat/m35-ttsemo-emotiontts-provider-with-emot' into dev
This commit is contained in:
commit
a697fa0a1e
23 files changed
+2951
-20
No files matched your search
@@ -2,12 +2,14 @@ package gateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
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 +29,32 @@ type transcribeRequest struct {
|
||||
// VOICE_TTS_VOICE_ID / VOICE_TTS_FORMAT 配置。Instruction 是可选的
|
||||
// 每请求情绪/语气覆盖,用于克隆音色(声音复刻);系统音色
|
||||
// (longxiaochun_v3)忽略它,绝不接收自由形式的指令。
|
||||
//
|
||||
// SessionID 是**可选**的会话标识(切片 M35,纯增量):发音人按该会话的**生效
|
||||
// 人格**解析 —— 会话绑定 → **全局默认人格** → 两者都没有才是空。
|
||||
//
|
||||
// 🔴 省略它**不等于**"没有角色":本口此时按**全局默认人格**选发音人(常规安装必
|
||||
// 有全局默认人格 ⇒ 仍走 `personas` 映射);只有连全局默认人格都没有时,才落到
|
||||
// `voice-tts.json` 的 `default` 字符(没配则不合成)。因此**不要**写成
|
||||
// `if req.SessionID == "" { personaID = "" }` —— 那会让本口永远用 `default` 那个
|
||||
// 声音,正是主人在意的「换人格还用老角色」(复审 r2/r4)。
|
||||
//
|
||||
// 客户端**绝不**通过它选音色:音色由服务端的 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"`
|
||||
}
|
||||
|
||||
// personaVoice 是"会按会话 persona 选发音人"的语音服务的**可选**扩展。只有
|
||||
// emotionTTS 门面(`*voicetts.Service`)实现它;既有链、stub 服务都不实现 ⇒
|
||||
// 未接 emotionTTS 时本片不为它额外查库,关闭态与引入之前逐字同行为。
|
||||
type personaVoice interface {
|
||||
// PrimaryActive 报告"按 persona 选发音"这一级是否真的在生效。
|
||||
PrimaryActive() bool
|
||||
}
|
||||
|
||||
// handleTranscribe 对上传的音频执行 STT 并返回识别出的
|
||||
@@ -100,11 +123,27 @@ func (s *Server) handleSpeech(w http.ResponseWriter, r *http.Request) {
|
||||
if req.Instruction != "" {
|
||||
ctx = voice.WithTTSInstruction(ctx, req.Instruction)
|
||||
}
|
||||
// 发音人按**本次请求的会话**的**生效人格**解析(切片 M35;复审 r2 P2-1):
|
||||
// 会话绑定 → 全局默认人格 → 都没有才是空。空不等于"没有角色" —— 主会话
|
||||
// (`web`)的 persona_id 恒为空且没有重绑路径,把它当空会让主会话的声音不跟
|
||||
// 全局人格走(主人抱怨的正是这件事)。
|
||||
//
|
||||
// 解析只做一次、只服务本次请求,绝不在注入时算一次并缓存成全局。
|
||||
//
|
||||
// 🔴 只有"真的会按 persona 选发音"的语音服务才做这次库读取:未启用 emotionTTS
|
||||
// 时注入的是既有链本身,此时本函数**不额外查库、不改 ctx** —— 关闭态与引入本
|
||||
// 切片之前逐字同行为(复审要求自证的那一条)。
|
||||
if pv, ok := s.voice.(personaVoice); ok && pv.PrimaryActive() {
|
||||
ctx = voicetts.WithPersonaID(ctx, s.envResolver.effectivePersona(ctx, req.SessionID))
|
||||
}
|
||||
audi, rung, err := ttsWithProvider(ctx, s.voice, req.Text)
|
||||
if err != nil {
|
||||
s.auditClaims(r, "audio.speech", "error")
|
||||
s.log.Warn("voice speech", "err", err)
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "tts_failed", "speech synthesis failed"))
|
||||
// 状态码统一 500(服务端配置缺失,不是客户端的错),但**错误码可区分**:
|
||||
// "这个角色没有发音人"与"合成失败了"是两种运维动作(复审 r2 建议 3)。
|
||||
code, msg := speechErrorCode(err)
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, code, "%s", msg))
|
||||
return
|
||||
}
|
||||
if len(audi.Data) == 0 {
|
||||
@@ -128,6 +167,25 @@ type voiceCloneRequest struct {
|
||||
Prefix string `json:"prefix,omitempty"`
|
||||
}
|
||||
|
||||
// speechErrorCode 把合成失败分类成**可区分**的错误码(状态码一律由调用方定 500)。
|
||||
//
|
||||
// 两种"没有声音"必须能分开排查:
|
||||
// - `tts_persona_unmapped`:这个角色在 `voice-tts.json` 的 `personas` 里没有映射
|
||||
// —— 运维要在面板里补一条(**绝不**用别的角色顶);
|
||||
// - `tts_no_character`:连生效人格都解析不到、且没配 `default` —— 运维要配 default
|
||||
// 或先选定人格;
|
||||
// - `tts_failed`:真正的合成失败(上游非 200 / 非音频 / 超时等)。
|
||||
func speechErrorCode(err error) (code, msg string) {
|
||||
switch {
|
||||
case errors.Is(err, voicetts.ErrPersonaUnmapped):
|
||||
return "tts_persona_unmapped", "this persona has no speech character configured"
|
||||
case errors.Is(err, voicetts.ErrNoCharacter):
|
||||
return "tts_no_character", "no speech character for this request"
|
||||
default:
|
||||
return "tts_failed", "speech synthesis failed"
|
||||
}
|
||||
}
|
||||
|
||||
// handleVoiceClone 经百炼声音注册 API(声音复刻 create_voice)从公开
|
||||
// 参考音频 URL 创建自定义 TTS 音色,并返回新的
|
||||
// voice_id。仅 owner + 带审计;参考 URL 绝不写入审计
|
||||
|
||||
@@ -51,6 +51,16 @@ func (s *audioStubVoice) CloneVoice(ctx context.Context, referenceURL, prefix st
|
||||
return s.cloneVoiceID, s.cloneErr
|
||||
}
|
||||
|
||||
// PrimaryActive 让本替身**实现**网关的 `personaVoice` 扩展并报告"按 persona 选发音
|
||||
// 的那一级正在生效"(复审 r3 P2-2)。
|
||||
//
|
||||
// 🔴 它存在是为了让"关闭态不查库"那条用例的变异**真能打红**:此前 `audioStubVoice`
|
||||
// 不实现 `PrimaryActive`,于是拦下 persona 读取的只有 `s.voice.(personaVoice)` 这个
|
||||
// **类型断言**,把 `&& pv.PrimaryActive()` 摘掉用例照样绿 —— 等于那条变异没被覆盖。
|
||||
// 现在类型断言会通过,拦住它的只剩这个布尔值。要表达"未生效"的替身请用
|
||||
// `inactivePersonaVoice`(`voice_emotiontts_test.go`)。
|
||||
func (s *audioStubVoice) PrimaryActive() bool { return true }
|
||||
|
||||
func TestHandleTranscribe(t *testing.T) {
|
||||
env := newEnv(t, nil, false)
|
||||
sv := &audioStubVoice{sttText: "你好世界"}
|
||||
|
||||
@@ -72,10 +72,20 @@ type turnEnvResolver struct {
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// defaultPersonaID 返回库中被标记为默认的人格 id(管理端记忆检索的回退用)。
|
||||
// 它转调解析器的同一实现,避免"两处各扫一遍 List()"而口径分叉。
|
||||
func (s *Server) defaultPersonaID() string {
|
||||
return defaultPersonaIDFrom(s.personas)
|
||||
}
|
||||
|
||||
// owner 返回 sessionID 一个面向 owner 回合的每回合环境:
|
||||
// 全访问 owner env(身份不变)加上该会话绑定的角色。
|
||||
// 空白角色意味着「继承全局默认」;loop 执行那最后一次
|
||||
// 回退,因此网关永远不必知道默认角色的 id。
|
||||
// 回退(`Options.PersonaID`),因此 **loop 的输入**是绑定本身而不是生效人格。
|
||||
//
|
||||
// 🔴 呈现层(TTS 音色)要的是**生效人格**,不是绑定 —— 主会话绑定恒为空,把它当成
|
||||
// 「没有角色」就会让主会话的声音不跟全局人格走。取生效人格请用 `effectivePersona`
|
||||
// / `ownerWithPersona`(同一份数据、同一次会话读取)。
|
||||
//
|
||||
// 构造走 loop.OwnerEnvFor —— 与 IM 的主人私聊车道**同一个入口**
|
||||
// (channelbridge/transcript.go 的 turnEnv):两条车道的 runEnv 因此逐字段同源,
|
||||
@@ -107,15 +117,66 @@ func (t turnEnvResolver) sessionPersona(ctx context.Context, sessionID string) s
|
||||
return personaID
|
||||
}
|
||||
|
||||
// defaultPersonaID 返回库中默认角色的人格 id,未接线人格
|
||||
// 存储或没有角色被标记为默认时返回 ""。它是
|
||||
// owner.yaml:defaultPersona 的网关侧视图,用于在没有会话时
|
||||
// 需要角色 id 的场合(管理记忆搜索回退,design/22 §4.5)。
|
||||
func (s *Server) defaultPersonaID() string {
|
||||
if s.personas == nil {
|
||||
// turnPersona 是"这一回合由哪个角色应答"的**单次解析**结果:
|
||||
//
|
||||
// - Binding 是会话的**显式**绑定(`chat_sessions.persona_id`;空 = 继承全局默认);
|
||||
// - Effective 是**生效人格** —— 会话有绑定就取绑定,否则取全局默认人格。
|
||||
//
|
||||
// 两者刻意分开:`loop.SessionEnv.PersonaID` 要的是**绑定**(空由 loop 自己回退到
|
||||
// `Options.PersonaID`,那是既有的唯一回退点,不许在这里替它决定);而**呈现层**
|
||||
// (TTS 音色、前端头像/名字/心情)要的是**生效人格** —— 前端 `lib/persona.ts` 的
|
||||
// `bound ?? defaultPersona(personas)` 就是这个口径。
|
||||
//
|
||||
// 🔴 为什么必须有 Effective:主会话(`web`)的 persona_id **恒为空且没有重绑路径**
|
||||
// (`session_persona_test.go`),而"空"的真实语义是「继承全局默认人格」。若把空
|
||||
// 当成"没有生效人格",主会话换全局人格后声音就不会跟 —— 这正是主人抱怨的那件事,
|
||||
// 而且发生在唯一能给普通聊天出声的路径上(复审 r2 P2-1)。
|
||||
type turnPersona struct {
|
||||
// Binding 是会话显式绑定(可能为空)。
|
||||
Binding string
|
||||
// Effective 是生效人格(会话绑定 → 全局默认人格 → 空)。
|
||||
Effective string
|
||||
}
|
||||
|
||||
// resolvePersona 一次解析出 Binding 与 Effective(**一次**会话读取,零重复 I/O)。
|
||||
func (t turnEnvResolver) resolvePersona(ctx context.Context, sessionID string) turnPersona {
|
||||
binding := t.sessionPersona(ctx, sessionID)
|
||||
if binding != "" {
|
||||
return turnPersona{Binding: binding, Effective: binding}
|
||||
}
|
||||
return turnPersona{Effective: t.defaultPersonaID()}
|
||||
}
|
||||
|
||||
// effectivePersona 返回该会话的**生效人格**:有绑定取绑定,否则取全局默认人格。
|
||||
// 两者都没有时返回 ""(= 连默认人格都没配),由调用方按"没有角色"处置。
|
||||
func (t turnEnvResolver) effectivePersona(ctx context.Context, sessionID string) string {
|
||||
return t.resolvePersona(ctx, sessionID).Effective
|
||||
}
|
||||
|
||||
// ownerWithPersona 与 owner 同义,但**顺带**返回这次解析出的 Binding/Effective ——
|
||||
// 语音收尾需要生效人格选音色,而 owner 已经在读会话,因此这里复用同一次读取
|
||||
// (WS 语音回合不为选音色多查一次库)。
|
||||
func (t turnEnvResolver) ownerWithPersona(ctx context.Context, sessionID string) (loop.SessionEnv, turnPersona) {
|
||||
tp := t.resolvePersona(ctx, sessionID)
|
||||
return loop.OwnerEnvFor(tp.Binding), tp
|
||||
}
|
||||
|
||||
// defaultPersonaID 返回库中被标记为默认的人格 id,未接线人格存储或没有角色被标记为
|
||||
// 默认时返回 ""。它是 `owner.yaml:defaultPersona` 在网关侧的**唯一**视图 —— 与
|
||||
// `(*Server).defaultPersonaID` 同源(后者直接转调这里,避免两处各写一份扫描)。
|
||||
func (t turnEnvResolver) defaultPersonaID() string {
|
||||
if t.personas == nil {
|
||||
return ""
|
||||
}
|
||||
for _, c := range s.personas.List() {
|
||||
return defaultPersonaIDFrom(t.personas)
|
||||
}
|
||||
|
||||
// defaultPersonaIDFrom 是上面那个口径的共享实现(`*Server` 与解析器共用)。
|
||||
func defaultPersonaIDFrom(personas PersonaStore) string {
|
||||
if personas == nil {
|
||||
return ""
|
||||
}
|
||||
for _, c := range personas.List() {
|
||||
if c.Default && c.PersonaID != "" {
|
||||
return c.PersonaID
|
||||
}
|
||||
|
||||
@@ -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 路由运行语音转文本与
|
||||
@@ -173,39 +174,50 @@ func (h *hub) runVoiceTurn(c *wsConn, req voiceInputRequest) {
|
||||
}
|
||||
// Web 主会话是主人的私密对话,因此它绑定全访问 env:记忆读+写,行为不变。
|
||||
// 角色来自该会话的绑定(design/22 §4.5)。
|
||||
env := h.envResolver.owner(ctx, req.SessionID)
|
||||
// 语音收尾要用**生效人格**选音色(会话绑定 → 全局默认人格),而 env 只带绑定
|
||||
// (loop 自己回退)。这里**复用同一次会话读取**,不为选音色多查一次库。
|
||||
env, tp := h.envResolver.ownerWithPersona(ctx, req.SessionID)
|
||||
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, tp.Effective, transcript))
|
||||
ctx = withPendingFullNotifier(ctx, c.notifyPendingFull)
|
||||
if h.orchestrator != nil {
|
||||
h.orchestrator.Submit(ctx, req.SessionID, msgs, env)
|
||||
return
|
||||
}
|
||||
h.runVoiceTurnDirect(ctx, c, req.SessionID, req.DeviceID, transcript, env)
|
||||
h.runVoiceTurnDirect(ctx, c, req.SessionID, req.DeviceID, tp.Effective, transcript, env)
|
||||
}
|
||||
|
||||
// runVoiceTurnDirect 是未接编排器时的旧直跑路径(未接线的构建 / 裸 hub 单元
|
||||
// 测试):转写之后的回合直接跑 loop,然后立即合成并回音频。它与编排器路径共用
|
||||
// 同一个帧构造(emitVoiceOutput),因此两条路径的 voice_output 契约一致。
|
||||
func (h *hub) runVoiceTurnDirect(ctx context.Context, c *wsConn, sessionID, deviceID, transcript string, env loop.SessionEnv) {
|
||||
func (h *hub) runVoiceTurnDirect(ctx context.Context, c *wsConn, sessionID, deviceID, personaID, transcript string, env loop.SessionEnv) {
|
||||
res, err := h.runner.Run(loop.WithSessionEnv(ctx, env), sessionID, []model.Message{model.TextMessage(model.RoleUser, transcript)})
|
||||
if err != nil {
|
||||
h.voiceLog().Warn("voice chat run", "err", err, "session", sessionID, "device_id", deviceID)
|
||||
h.voiceError(c, "voice chat failed")
|
||||
return
|
||||
}
|
||||
h.synthesizeVoiceReply(ctx, c, sessionID, deviceID, transcript, voiceReplyText(res))
|
||||
h.synthesizeVoiceReply(ctx, c, sessionID, deviceID, personaID, transcript, voiceReplyText(res))
|
||||
}
|
||||
|
||||
// voiceReplySink 返回本次语音请求的收尾回调:回合结算后取助手正文 → TTS →
|
||||
// **定向**回发起它的那根连接。它是 C1 的核心接线点 —— 回合异步完成,只有结算处
|
||||
// 知道助手正文。
|
||||
//
|
||||
// personaID 是**本回合的生效人格**(会话绑定 → 全局默认人格;两者都没有才是空,
|
||||
// 见 `turnEnvResolver.resolvePersona`)。🔴 它**不是** `env.PersonaID`
|
||||
// (那是"绑定本身",空表示继承,由 loop 自己回退)—— 未绑定 ≠ 空:主会话永远未绑定,
|
||||
// 但只要有全局默认人格,这里就非空。把空当成"没有角色"会让主会话的声音落到
|
||||
// `voice-tts.json` 的 `default`(复审 r2 P2-1 修掉的正是这个)。
|
||||
//
|
||||
// 它随回调一起传下去,使合成按这一回合的角色选发音人 —— 而不是进程启动时定死一个。
|
||||
// 它不是身份输入(身份仍由 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 +225,32 @@ 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;
|
||||
// - **发音人按本回合的生效人格现解析**(`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
|
||||
}
|
||||
// 把**本回合的生效人格**交给语音面选发音人。空 = **连全局默认人格都没有**
|
||||
// (不是"会话无绑定"——未绑定但存在全局默认人格时这里非空)⇒ 语音面才走
|
||||
// `voice-tts.json` 的 `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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
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 ⇒ 生效人格只能来自**全局默认人格**;本用例没有接人格库,
|
||||
// 因此连默认人格都没有 ⇒ 才落到配置的 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])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpeechEndpointDisabledVoiceDoesNotReadPersona 是**关闭态零行为变化**的自证
|
||||
// (复审要求在当前 tip 上重跑的那一条,而我这一版动了 `gateway/audio.go`):
|
||||
//
|
||||
// 注入的语音服务**实现了 personaVoice 却报告未生效**(`inactivePersonaVoice`,
|
||||
// 模拟"门面在、这一级关着")时,语音合成**一次都不查会话人格** —— 与引入本切片
|
||||
// 之前逐字同行为;反之(真 emotionTTS 门面,PrimaryActive=true)才会查,说明这个闸
|
||||
// 不是"永远不查"。
|
||||
//
|
||||
// 变异:去掉 handleSpeech 里的 `&& pv.PrimaryActive()`(只留类型断言)⇒ 本用例
|
||||
// 第一段红。🔴 这条变异此前**打不红**,因为替身不实现 `PrimaryActive`,拦住读库的
|
||||
// 只有类型断言(复审 r3 P2-2);现在替身实现了它,布尔值才是唯一的闸。
|
||||
func TestSpeechEndpointDisabledVoiceDoesNotReadPersona(t *testing.T) {
|
||||
env := newEnv(t, nil, false)
|
||||
stub := &inactivePersonaVoice{audioStubVoice: audioStubVoice{
|
||||
ttsAudio: mvoice.Audio{Format: "wav", Data: fakeWAV("chain")},
|
||||
}}
|
||||
env.srv.SetVoice(stub)
|
||||
counter := &countingHistory{fakeHistory: &fakeHistory{}}
|
||||
env.srv.envResolver = turnEnvResolver{history: counter, log: env.srv.log}
|
||||
token := env.token(t, auth.RoleOwner)
|
||||
|
||||
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/audio/speech", map[string]any{
|
||||
"text": "你好", "sessionId": "web:sub-1",
|
||||
}, token)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", resp.StatusCode, body)
|
||||
}
|
||||
if got := counter.Calls(); got != 0 {
|
||||
t.Fatalf("未生效的 personaVoice 却查了 %d 次会话人格(关闭态行为变了)", got)
|
||||
}
|
||||
if string(body) != string(fakeWAV("chain")) {
|
||||
t.Fatalf("关闭态的音频不是既有链产物: %q", string(body))
|
||||
}
|
||||
|
||||
// 对照:接上真的 emotionTTS 门面后,同一个端点会查一次人格(闸不是恒关)。
|
||||
env2 := newEnv(t, nil, false)
|
||||
env2.srv.SetVoice(emotionTTSService(t, &vocalRecorder{}, voicetts.Config{Personas: personaCharacters}, &silentBase{}))
|
||||
counter2 := &countingHistory{fakeHistory: &fakeHistory{}}
|
||||
env2.srv.envResolver = turnEnvResolver{history: counter2, log: env2.srv.log}
|
||||
if resp, body := doReq(t, http.MethodPost, env2.ts.URL+"/api/v1/audio/speech", map[string]any{
|
||||
"text": "你好", "sessionId": "web:sub-1",
|
||||
}, env.token(t, auth.RoleOwner)); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("对照组 status = %d, body = %s", resp.StatusCode, body)
|
||||
}
|
||||
if got := counter2.Calls(); got != 1 {
|
||||
t.Fatalf("启用 emotionTTS 后应查 1 次人格, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// countingHistory 记录 SessionPersona 被查了几次(同时满足 HistoryStore 的其余方法)。
|
||||
type countingHistory struct {
|
||||
*fakeHistory
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
}
|
||||
|
||||
// SessionPersona 计数并回一个固定的 persona。
|
||||
func (h *countingHistory) SessionPersona(context.Context, string) (string, error) {
|
||||
h.mu.Lock()
|
||||
h.calls++
|
||||
h.mu.Unlock()
|
||||
return "furina", nil
|
||||
}
|
||||
|
||||
// Calls 返回查询次数。
|
||||
func (h *countingHistory) Calls() int {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return h.calls
|
||||
}
|
||||
|
||||
// inactivePersonaVoice 是"**实现了** personaVoice 但报告这一级未生效"的替身
|
||||
// (模拟 emotionTTS 门面存在、`enabled` 关着的情形)。它让 `PrimaryActive()` 成为
|
||||
// 关闭态唯一的闸 —— 这样把 `&& pv.PrimaryActive()` 摘掉的变异才会打红。
|
||||
type inactivePersonaVoice struct{ audioStubVoice }
|
||||
|
||||
// PrimaryActive 报告"按 persona 选发音"的那一级**未**生效。
|
||||
func (i *inactivePersonaVoice) PrimaryActive() bool { return false }
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package gateway
|
||||
|
||||
// 本文件是复审 r2 P2-1 的**回归锁**:主会话(`web`)的 `persona_id` 恒为空且没有
|
||||
// 重绑路径,而"空"的真实语义是「**继承全局默认人格**」(`loop/persona.go` 回退到
|
||||
// `Options.PersonaID`;前端 `lib/persona.ts` 的 `bound ?? defaultPersona(personas)`)。
|
||||
//
|
||||
// 若把空当成"没有生效人格",主会话聊天是 furina、声音却是 `voice-tts.json` 的
|
||||
// `default` 那个人 —— 主人逐字抱怨的那件事,而且发生在唯一能给普通聊天出声的路径上。
|
||||
//
|
||||
// 两条用例分别钉住 REST 与 WS 两条既有出站口;变异(把 effectivePersona 退回
|
||||
// 「空 = 无」)必须打红。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
voicetts "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/tts"
|
||||
)
|
||||
|
||||
// defaultPersonaRegistry 是一个"全局默认人格 = furina"的人格库。
|
||||
func defaultPersonaRegistry() PersonaStore {
|
||||
return &stubPersonas{list: []personareg.Character{
|
||||
{ID: "Cyrene", Name: "Cyrene", PersonaID: "cyrene"},
|
||||
{ID: "芙宁娜", Name: "芙宁娜", PersonaID: "furina", Default: true},
|
||||
}}
|
||||
}
|
||||
|
||||
// unboundHistory 模拟**主会话**:SessionPersona 恒报空串(继承全局默认)。
|
||||
type unboundHistory struct{ *fakeHistory }
|
||||
|
||||
func (h unboundHistory) SessionPersona(context.Context, string) (string, error) { return "", nil }
|
||||
|
||||
// TestSpeechEndpointUnboundSessionInheritsGlobalPersona 是复审那条用例的复现:
|
||||
// 主会话(未绑定)+ 全局默认人格 furina + `default` 配成 cyrene 的角色 ⇒
|
||||
// 必须用 **furina 的** 声音(不是 default)。
|
||||
func TestSpeechEndpointUnboundSessionInheritsGlobalPersona(t *testing.T) {
|
||||
env := newEnv(t, nil, false)
|
||||
rec := &vocalRecorder{}
|
||||
// default 故意指向 cyrene 的角色:修好之前这里会被选中(复审报的红)。
|
||||
env.srv.SetVoice(emotionTTSService(t, rec, voicetts.Config{
|
||||
Personas: personaCharacters,
|
||||
Default: personaCharacters["cyrene"],
|
||||
}, &silentBase{}))
|
||||
env.srv.envResolver = turnEnvResolver{
|
||||
history: unboundHistory{fakeHistory: &fakeHistory{}},
|
||||
personas: defaultPersonaRegistry(),
|
||||
log: env.srv.log,
|
||||
}
|
||||
token := env.token(t, auth.RoleOwner)
|
||||
|
||||
// 主会话就是 "web"(与 history.MainSessionID 同值;这里只声明"未绑定"这一事实)。
|
||||
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/audio/speech", map[string]any{
|
||||
"text": "你好,我是苏梦。", "sessionId": "web",
|
||||
}, token)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", resp.StatusCode, body)
|
||||
}
|
||||
got := rec.Voices()
|
||||
want := personaCharacters["furina"]
|
||||
if len(got) != 1 || got[0] != want {
|
||||
t.Fatalf("主会话(继承全局人格 furina)用了 %v 的声音,want [%s] —— 这正是主人抱怨的那件事", got, want)
|
||||
}
|
||||
if string(body) != string(fakeWAV(want)) {
|
||||
t.Fatalf("响应体不是 furina 的声音: %q", string(body))
|
||||
}
|
||||
}
|
||||
|
||||
// TestVoiceOutputUnboundSessionInheritsGlobalPersona 是同一条口径在 WS 口上的复现:
|
||||
// 帧里的音频必须来自**全局默认人格**的角色。
|
||||
func TestVoiceOutputUnboundSessionInheritsGlobalPersona(t *testing.T) {
|
||||
hb := newTestHub(&stubRunner{res: &loop.Result{Text: "你好,我是苏梦。", Steps: 1, StopReason: "stop"}}, nil)
|
||||
hb.envResolver = turnEnvResolver{
|
||||
history: unboundHistory{fakeHistory: &fakeHistory{}},
|
||||
personas: defaultPersonaRegistry(),
|
||||
log: sourcedLog(nil),
|
||||
}
|
||||
rec := &vocalRecorder{}
|
||||
hb.voice = emotionTTSService(t, rec, voicetts.Config{
|
||||
Personas: personaCharacters,
|
||||
Default: personaCharacters["cyrene"],
|
||||
}, &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 := personaCharacters["furina"]
|
||||
if string(raw) != string(fakeWAV(want)) {
|
||||
t.Fatalf("主会话的帧里不是 furina 的声音: %q", string(raw))
|
||||
}
|
||||
if got := rec.Voices(); len(got) != 1 || got[0] != want {
|
||||
t.Fatalf("请求体里的 voice = %v, want [%s]", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpeechEndpointPersonaUnmappedErrorCode 钉住复审 r2 建议 3:状态码仍是 500
|
||||
// (服务端配置缺失,不是客户端的错),但**错误码可区分** —— 不许跟一般合成失败混成一个码。
|
||||
func TestSpeechEndpointPersonaUnmappedErrorCode(t *testing.T) {
|
||||
env := newEnv(t, nil, false)
|
||||
env.srv.SetVoice(emotionTTSService(t, &vocalRecorder{}, voicetts.Config{
|
||||
Personas: personaCharacters,
|
||||
}, &silentBase{}))
|
||||
env.srv.envResolver = turnEnvResolver{
|
||||
history: defaultPersonaRegistryHistory(),
|
||||
personas: defaultPersonaRegistry(),
|
||||
log: env.srv.log,
|
||||
}
|
||||
token := env.token(t, auth.RoleOwner)
|
||||
|
||||
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/audio/speech", map[string]any{
|
||||
"text": "你好", "sessionId": "web:sub-7",
|
||||
}, token)
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500(服务端配置缺失)", resp.StatusCode)
|
||||
}
|
||||
code := errorCodeOf(t, body)
|
||||
if code != "tts_persona_unmapped" {
|
||||
t.Fatalf("error.code = %q, want tts_persona_unmapped(body=%s)", code, body)
|
||||
}
|
||||
}
|
||||
|
||||
// defaultPersonaRegistryHistory 是一个"会话已绑定到没有 TTS 映射的人格"的存储。
|
||||
func defaultPersonaRegistryHistory() HistoryStore {
|
||||
return personaHistory{fakeHistory: &fakeHistory{}, persona: "stranger"}
|
||||
}
|
||||
Reference in new issue
Block a user