- voice/Chain+Service: STTWithProvider/TTSWithProvider(真实档位cloud/local/edge-tts/espeak/silent), 旧方法改薄包装
- gateway: transcribe空文本带{text,provider} / voice_output空音频带provider / speech空音频tts_empty+X-Voice-Provider头
- middleware: CORS Access-Control-Expose-Headers: X-Voice-Provider
- 可选接口 voiceProviderReporter(type断言透传), 自定义实现回退silent
- 新增测试10个(provider_signal/voice_signal), build/vet/test全绿+交叉编译
- Docs/api-client v0.23 + api-reference CORS 同步
178 lines
6.7 KiB
Go
178 lines
6.7 KiB
Go
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/<format>) 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"
|
|
}
|
|
}
|