fix(voice): 主/子系统会话的发音人跟「生效人格」走(r2 P2-1)+ 错误码可区分
P2-1(实质):主会话(web)的 persona_id 恒为空且无重绑路径,而"空"的真实语义是 「继承全局默认人格」(loop/persona.go 回退 Options.PersonaID;前端 lib/persona.ts 的 bound ?? defaultPersona(personas) 是同一口径)。此前只有 TTS 把空读成"没有生效人格" ⇒ 落到 voice-tts.json 的 default:主会话聊天是 furina、声音却是 default 那个人, 换全局人格后也不跟声 —— 正是主人抱怨的那件事,且发生在唯一能给普通聊天出声的路径。 修法: - turnEnvResolver 新增 resolvePersona/effectivePersona(绑定 → 全局默认人格 → ""), 默认人格扫描抽成 defaultPersonaIDFrom 一处(*Server.defaultPersonaID 转调它); - ownerWithPersona 复用**同一次**会话读取同时给出 loop 用的 binding 与呈现用的 effective —— WS 语音回合为选音色不新增任何 I/O; - REST:现有闸内改用 effectivePersona;WS:voice.go 那次 env 解析处取一次传给 sink。 因此 `default` 只剩"连默认人格都没有"这一种用途。 - service.go 那句"换人格后用老角色声音不可能发生"收回为限定说法:本包无缓存、无兜底 角色、未映射即拒绝,但**调用方必须传生效人格**(网关两处已修)。 回归锁:voice_persona_inherit_test.go 复现复审用例(名字不同、两条口各一条)—— 未绑定会话 + 全局默认 furina + default 指向 cyrene ⇒ 必须用 furina 的声音; 变异(effectivePersona 退回「空 = 无」)实测两条都红,报文与复审一致。 建议 3(顺手做):persona 未映射时状态码仍 500(服务端配置缺失),但错误码换成 tts_persona_unmapped / tts_no_character,不再与一般合成失败混成一个码; 用例 TestSpeechEndpointPersonaUnmappedErrorCode 钉住。 P2-2(nit):voicetts.go 开头删掉「格式、」两字(该键已删除)。 go build ./... + go vet ./... + go test(含 -race)全绿。
This commit is contained in:
1 parent
8820b0b316
commit
e684061e56
6 files changed
+259
-20
No files matched your search
@@ -2,6 +2,7 @@ package gateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -115,21 +116,27 @@ func (s *Server) handleSpeech(w http.ResponseWriter, r *http.Request) {
|
||||
if req.Instruction != "" {
|
||||
ctx = voice.WithTTSInstruction(ctx, req.Instruction)
|
||||
}
|
||||
// 发音人按**本次请求的会话**所绑定的人格解析(切片 M35)。空 sessionId 或
|
||||
// 会话无绑定 ⇒ personaID 为空 ⇒ 语音面走 default 分支(没有 default 就不合成)。
|
||||
// 发音人按**本次请求的会话**的**生效人格**解析(切片 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.sessionPersona(ctx, req.SessionID))
|
||||
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 {
|
||||
@@ -153,6 +160,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 绝不写入审计
|
||||
|
||||
Reference in new issue
Block a user