package gateway import ( "encoding/json" "net/http" "strconv" "strings" apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice" ) // maxAudioRequestBytes caps the JSON body a /api/v1/audio/* request may carry so // a huge base64 audio payload cannot exhaust memory. const maxAudioRequestBytes = 32 << 20 // transcribeRequest is the POST /api/v1/audio/transcribe body. Audio is a base64 // (or data: URI) blob; Format is its encoding ("wav"/"mp3"/"ogg"). type transcribeRequest struct { Audio string `json:"audio"` Format string `json:"format"` DeviceID string `json:"deviceId,omitempty"` } // speechRequest is the POST /api/v1/audio/speech body. Voice/format are accepted // for forward compatibility; synthesis currently follows the server's // VOICE_TTS_VOICE_ID / VOICE_TTS_FORMAT config. Instruction is an optional // per-request emotion/tone override for a cloned voice (声音复刻); a system voice // (longxiaochun_v3) ignores it and never receives a free-form instruction. type speechRequest struct { Text string `json:"text"` Voice string `json:"voice,omitempty"` Format string `json:"format,omitempty"` Instruction string `json:"instruction,omitempty"` } // handleTranscribe runs STT on the uploaded audio and returns the recognized // text. It is the REST source for native clients, bypassing the 4KB WebSocket // frame limit that would truncate a base64 audio blob. 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 synthesizes speech for the given text and writes the raw audio // bytes (audio/) back — a binary response, not a JSON envelope. 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) } 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")) 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 is the POST /api/v1/audio/voice-clone body. URL is the public // reference audio URL; prefix is the optional voice-name prefix (≤10 alnum chars). // Neither is logged, and the URL is not persisted (owner + audit only, red line). type voiceCloneRequest struct { URL string `json:"url"` Prefix string `json:"prefix,omitempty"` } // handleVoiceClone creates a custom TTS voice from a public reference audio URL // via the Bailian voice-enrollment API (声音复刻 create_voice) and returns the new // voice_id. Owner-only + audited; the reference URL is never written to the audit // trail or the log (red line). 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") // Do not log the cause: a provider error body may echo the reference 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 maps an audio format to its Content-Type for the speech response. 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" } }