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/)—— 二进制响应,不是 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" } }