Files
Cyrene_v2/internal/services/gateway/audio.go
T
AskaEth e684061e56 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)全绿。
2026-10-26 13:21:44 +08:00

229 lines
9.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package 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 体大小,
// 使巨大的 base64 音频载荷无法耗尽内存。
const maxAudioRequestBytes = 32 << 20
// transcribeRequest 是 POST /api/v1/audio/transcribe 的请求体。Audio 是 base64
// (或 data: URI)数据块;Format 是其编码("wav"/"mp3"/"ogg")。
type transcribeRequest struct {
Audio string `json:"audio"`
Format string `json:"format"`
DeviceID string `json:"deviceId,omitempty"`
}
// speechRequest 是 POST /api/v1/audio/speech 的请求体。Voice/format 为前向
// 兼容而被接受;合成目前遵循服务端的
// 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"`
}
// personaVoice 是"会按会话 persona 选发音人"的语音服务的**可选**扩展。只有
// emotionTTS 门面(`*voicetts.Service`)实现它;既有链、stub 服务都不实现 ⇒
// 未接 emotionTTS 时本片不为它额外查库,关闭态与引入之前逐字同行为。
type personaVoice interface {
// PrimaryActive 报告"按 persona 选发音"这一级是否真的在生效。
PrimaryActive() bool
}
// handleTranscribe 对上传的音频执行 STT 并返回识别出的
// 文本。它是原生客户端的 REST 来源,绕过 4KB WebSocket
// 帧上限(该上限会截断 base64 音频块)。
func (s *Server) handleTranscribe(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxAudioRequestBytes)
var req transcribeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.auditClaims(r, "audio.transcribe", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
if s.voice == nil {
s.auditClaims(r, "audio.transcribe", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "voice_unavailable", "voice service is not configured"))
return
}
audio, err := decodeVoiceAudio(req.Audio, req.Format)
if err != nil {
s.auditClaims(r, "audio.transcribe", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "audio could not be decoded"))
return
}
if len(audio.Data) == 0 {
s.auditClaims(r, "audio.transcribe", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "audio is required"))
return
}
text, rung, err := sttWithProvider(r.Context(), s.voice, audio)
if err != nil {
s.auditClaims(r, "audio.transcribe", "error")
s.log.Warn("voice transcribe", "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "stt_failed", "speech transcription failed"))
return
}
s.auditClaims(r, "audio.transcribe", "ok")
out := map[string]any{"text": text}
if sig := providerSignal(rung, strings.TrimSpace(text) == ""); sig != "" {
out["provider"] = sig
}
writeJSON(w, http.StatusOK, out)
}
// handleSpeech 为给定文本合成语音并写回原始音频
// 字节(audio/<format>)—— 二进制响应,不是 JSON 信封。
func (s *Server) handleSpeech(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxAudioRequestBytes)
var req speechRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.auditClaims(r, "audio.speech", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
if strings.TrimSpace(req.Text) == "" {
s.auditClaims(r, "audio.speech", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "text is required"))
return
}
if s.voice == nil {
s.auditClaims(r, "audio.speech", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "voice_unavailable", "voice service is not configured"))
return
}
ctx := r.Context()
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)
// 状态码统一 500(服务端配置缺失,不是客户端的错),但**错误码可区分**:
// "这个角色没有发音人"与"合成失败了"是两种运维动作(复审 r2 建议 3)。
code, msg := speechErrorCode(err)
writeError(w, apperr.Wrap(apperr.ErrInternal, code, "%s", msg))
return
}
if len(audi.Data) == 0 {
s.auditClaims(r, "audio.speech", "error")
w.Header().Set(voiceProviderHeader, providerSignal(rung, true))
writeError(w, apperr.Wrap(apperr.ErrInternal, "tts_empty", "no audio synthesized"))
return
}
s.auditClaims(r, "audio.speech", "ok")
w.Header().Set("Content-Type", audioMime(audi.Format))
w.Header().Set("Content-Length", strconv.Itoa(len(audi.Data)))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(audi.Data)
}
// voiceCloneRequest 是 POST /api/v1/audio/voice-clone 的请求体。URL 是公开的
// 参考音频 URL;prefix 是可选音色名前缀(≤10 个字母数字字符)。
// 二者都不记日志,URL 也不持久化(仅 owner + 审计,红线)。
type voiceCloneRequest struct {
URL string `json:"url"`
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 绝不写入审计
// 记录或日志(红线)。
func (s *Server) handleVoiceClone(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxAudioRequestBytes)
var req voiceCloneRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.auditClaims(r, "audio.voice-clone", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
if strings.TrimSpace(req.URL) == "" {
s.auditClaims(r, "audio.voice-clone", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "url is required"))
return
}
if s.voice == nil {
s.auditClaims(r, "audio.voice-clone", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "voice_unavailable", "voice service is not configured"))
return
}
voiceID, err := s.voice.CloneVoice(r.Context(), req.URL, req.Prefix)
if err != nil {
s.auditClaims(r, "audio.voice-clone", "error")
// 不要记录原因:provider 的错误体可能回显参考 URL。
s.log.Warn("voice clone failed")
writeError(w, apperr.Wrap(apperr.ErrInternal, "voice_clone_failed", "voice clone failed"))
return
}
s.auditClaims(r, "audio.voice-clone", "ok")
writeJSON(w, http.StatusOK, map[string]any{"voiceId": voiceID})
}
// audioMime 把音频格式映射为其用于语音响应的 Content-Type。
func audioMime(format string) string {
switch strings.ToLower(format) {
case "mp3", "mpeg":
return "audio/mpeg"
case "ogg", "oga":
return "audio/ogg"
case "webm":
return "audio/webm"
default:
return "audio/wav"
}
}