Files
Cyrene_v2/internal/services/gateway/audio.go
T
Aska 73dabb9dde
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(voice): 阿里百炼 STT/TTS 主链路接通(base64 STT + cosyvoice TTS按voice_id + REST /audio/* + voice_input.deviceId)
- voice/dashscope.go 新建:DashscopeSTT(qwen-audio-3.0-asr-flash, base64 data URI→text) + DashscopeTTS(cosyvoice 按 voice_id, 取非流式 24h URL 下载字节)
- cloud.go newDashboardSTT/TTS 由空 endpoint stub 改为真实百炼 adapter
- chain.go gating 从 DashscopeAPIKey!="" 改为 voiceConfigured(cfg); silent 兜底保留
- config.VoiceConfig 扩展 DASHSCOPE_BASE_URL/VOICE_STT_*/VOICE_TTS_* 字段(必填 DASHSCOPE_API_KEY)
- gateway: POST /api/v1/audio/{transcribe,speech} (owner+审计+MaxBytesReader); voiceInputRequest 增 deviceId 并透传 runner.Run
- 实测: cosyvoice-v3.5-plus+longxiaochun 回 418(引擎拒绝), 改用 cosyvoice-v3-flash+longxiaochun_v3; TTS 你好我是昔涟->106124B wav -> STT 回 你好我是西联, 链返回真实字节/文本
- 单测: dashscope_test/config voice defaults/gateway audio_test; go build/vet/test 全绿
- 声音复刻(响昔涟音色)待公网参考音频; 不做 create_voice
2026-09-08 06:06:02 +08:00

123 lines
4.2 KiB
Go

package gateway
import (
"encoding/json"
"net/http"
"strconv"
"strings"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
)
// 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.
type speechRequest struct {
Text string `json:"text"`
Voice string `json:"voice,omitempty"`
Format string `json:"format,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, err := s.voice.STT(r.Context(), 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")
writeJSON(w, http.StatusOK, map[string]any{"text": text})
}
// 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
}
audi, err := s.voice.TTS(r.Context(), 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")
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)
}
// 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"
}
}