Files
AskaEth b90811fae4 docs(voice): 收口「没会话/没绑定就走 default」的镜像注释(r4)
唯一 finding:audio.go 的 speechRequest.SessionID 注释原本写「省略它时本口没有会话
概念 ⇒ 走 emotionTTS 的 default 分支」——**反了**。真实链路是
effectivePersona(ctx,"") → sessionPersona("") 为空**不是终点** → Effective =
defaultPersonaID() → 常规安装必有全局默认人格 ⇒ personaId 非空 ⇒ 走 personas 映射。
照那句话改的人会写出 `if req.SessionID == "" { personaID = "" }`,正是 r2 修掉的病。
改为:省略时按**全局默认人格**选发音人(会话绑定 → 全局默认人格 → 两者都没有才落到
default 字符),并显式写了"不要写成 if SessionID == \"\" { personaID = \"\" }"。

顺手同根三处(不留第四轮):
- tts/config.go 的 Config.Default 注释:成因表「会话无绑定 / 读取失败 / 该出口本身
  没有会话」已过时 ⇒ 改为「空串的唯一合法成因是连全局默认人格都没有」,并写明不是
  "会话未绑定"、也不是"出站口没有会话"。
- runtimeconfig/voicetts.go 的 Default 字段注释与文件头同族措辞同步;
- selection.go 的 WithPersonaID / Resolve 分支表、service.go 的分支小标把
  「解析不到」统一改成「连全局默认人格都没有」;
- voice_emotiontts_test.go 的 TestSpeechEndpointNoSessionUsesDefault 注释同步(该用例
  没接人格库,所以确实落到 default —— 但成因写对了);
- wire_voice_tts_test.go nit:改成「已废弃的全局 `character` 不生效」(墓碑键仍在)。

纯注释:`git diff` 里非注释改动行为空。go build/vet 通过;四包 go test -count=1 全绿。
2026-10-26 13:40:00 +08:00

236 lines
10 KiB
Go
Raw Permalink 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,纯增量):发音人按该会话的**生效
// 人格**解析 —— 会话绑定 → **全局默认人格** → 两者都没有才是空。
//
// 🔴 省略它**不等于**"没有角色":本口此时按**全局默认人格**选发音人(常规安装必
// 有全局默认人格 ⇒ 仍走 `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 并返回识别出的
// 文本。它是原生客户端的 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"
}
}