feat(voice): 百炼声音复刻create_voice + TTS instruction情绪驱动(主TTS-昔涟复刻音色)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

This commit is contained in:
Aska committed 2026-09-08 06:48:15 +08:00
1 parent 73dabb9dde
commit 03d7050e1e
15 files changed
+686 -30

No files matched your search

+55 -5
View File
@@ -7,6 +7,7 @@ import (
"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
@@ -23,11 +24,14 @@ type transcribeRequest struct {
// 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.
// 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"`
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
@@ -88,7 +92,11 @@ func (s *Server) handleSpeech(w http.ResponseWriter, r *http.Request) {
writeError(w, apperr.Wrap(apperr.ErrInternal, "voice_unavailable", "voice service is not configured"))
return
}
audi, err := s.voice.TTS(r.Context(), req.Text)
ctx := r.Context()
if req.Instruction != "" {
ctx = voice.WithTTSInstruction(ctx, req.Instruction)
}
audi, err := s.voice.TTS(ctx, req.Text)
if err != nil {
s.auditClaims(r, "audio.speech", "error")
s.log.Warn("voice speech", "err", err)
@@ -107,6 +115,48 @@ func (s *Server) handleSpeech(w http.ResponseWriter, r *http.Request) {
_, _ = 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) {