feat(voice): 百炼声音复刻create_voice + TTS instruction情绪驱动(主TTS-昔涟复刻音色)
This commit is contained in:
1 parent
73dabb9dde
commit
03d7050e1e
15 files changed
+686
-30
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
@@ -21,6 +22,13 @@ type audioStubVoice struct {
|
||||
gotText string
|
||||
sttCalled bool
|
||||
ttsCalled bool
|
||||
|
||||
cloneVoiceID string
|
||||
cloneErr error
|
||||
cloneCalled bool
|
||||
gotCloneURL string
|
||||
gotClonePfx string
|
||||
gotInstr string
|
||||
}
|
||||
|
||||
func (s *audioStubVoice) STT(ctx context.Context, audio voice.Audio) (string, error) {
|
||||
@@ -32,9 +40,17 @@ func (s *audioStubVoice) STT(ctx context.Context, audio voice.Audio) (string, er
|
||||
func (s *audioStubVoice) TTS(ctx context.Context, text string) (voice.Audio, error) {
|
||||
s.ttsCalled = true
|
||||
s.gotText = text
|
||||
s.gotInstr = voice.TTSInstructionFromContext(ctx)
|
||||
return s.ttsAudio, s.ttsErr
|
||||
}
|
||||
|
||||
func (s *audioStubVoice) CloneVoice(ctx context.Context, referenceURL, prefix string) (string, error) {
|
||||
s.cloneCalled = true
|
||||
s.gotCloneURL = referenceURL
|
||||
s.gotClonePfx = prefix
|
||||
return s.cloneVoiceID, s.cloneErr
|
||||
}
|
||||
|
||||
func TestHandleTranscribe(t *testing.T) {
|
||||
env := newEnv(t, nil, false)
|
||||
sv := &audioStubVoice{sttText: "你好世界"}
|
||||
@@ -122,3 +138,97 @@ func TestHandleSpeech_EmptyText(t *testing.T) {
|
||||
t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSpeech_InstructionPassthrough(t *testing.T) {
|
||||
env := newEnv(t, nil, false)
|
||||
sv := &audioStubVoice{ttsAudio: voice.Audio{Format: "wav", Data: []byte("audio-bytes")}}
|
||||
env.srv.SetVoice(sv)
|
||||
|
||||
token := env.token(t, auth.RoleOwner)
|
||||
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/audio/speech", map[string]any{
|
||||
"text": "你好,我是昔涟",
|
||||
"instruction": "语气开朗俏皮,像女朋友撒娇",
|
||||
}, token)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", resp.StatusCode, body)
|
||||
}
|
||||
if !sv.ttsCalled {
|
||||
t.Fatal("TTS not called")
|
||||
}
|
||||
if sv.gotText != "你好,我是昔涟" {
|
||||
t.Errorf("TTS text = %q, want 你好,我是昔涟", sv.gotText)
|
||||
}
|
||||
if sv.gotInstr != "语气开朗俏皮,像女朋友撒娇" {
|
||||
t.Errorf("instruction threaded = %q, want the per-request instruction", sv.gotInstr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSpeech_NoInstruction(t *testing.T) {
|
||||
env := newEnv(t, nil, false)
|
||||
sv := &audioStubVoice{ttsAudio: voice.Audio{Format: "wav", Data: []byte("audio-bytes")}}
|
||||
env.srv.SetVoice(sv)
|
||||
|
||||
token := env.token(t, auth.RoleOwner)
|
||||
resp, _ := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/audio/speech", map[string]any{
|
||||
"text": "你好",
|
||||
}, token)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if sv.gotInstr != "" {
|
||||
t.Errorf("instruction = %q, want empty when not supplied", sv.gotInstr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleVoiceClone(t *testing.T) {
|
||||
env := newEnv(t, nil, false)
|
||||
sv := &audioStubVoice{cloneVoiceID: "cosyvoice-v3-flash-cyrene-abc123"}
|
||||
env.srv.SetVoice(sv)
|
||||
|
||||
token := env.token(t, auth.RoleOwner)
|
||||
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/audio/voice-clone", map[string]any{
|
||||
"url": "https://example.com/ref.wav",
|
||||
"prefix": "xilian",
|
||||
}, token)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", resp.StatusCode, body)
|
||||
}
|
||||
if !sv.cloneCalled {
|
||||
t.Fatal("CloneVoice not called")
|
||||
}
|
||||
if sv.gotCloneURL != "https://example.com/ref.wav" || sv.gotClonePfx != "xilian" {
|
||||
t.Errorf("clone url/prefix = %q / %q, want the request values", sv.gotCloneURL, sv.gotClonePfx)
|
||||
}
|
||||
env2 := decodeBody(t, body)
|
||||
if got := string(env2["data"]); got == "" {
|
||||
t.Errorf("data envelope empty: %s", body)
|
||||
}
|
||||
if !strings.Contains(string(env2["data"]), "cosyvoice-v3-flash-cyrene-abc123") {
|
||||
t.Errorf("voiceId missing: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleVoiceClone_AuthRequired(t *testing.T) {
|
||||
env := newEnv(t, nil, false)
|
||||
env.srv.SetVoice(&audioStubVoice{})
|
||||
|
||||
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/audio/voice-clone", map[string]any{
|
||||
"url": "https://example.com/ref.wav",
|
||||
}, "")
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleVoiceClone_EmptyURL(t *testing.T) {
|
||||
env := newEnv(t, nil, false)
|
||||
env.srv.SetVoice(&audioStubVoice{})
|
||||
|
||||
token := env.token(t, auth.RoleOwner)
|
||||
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/audio/voice-clone", map[string]any{
|
||||
"url": " ",
|
||||
}, token)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
@@ -1090,8 +1090,9 @@ type stubVoice struct {
|
||||
ttsErr error
|
||||
}
|
||||
|
||||
func (s *stubVoice) STT(_ context.Context, _ voice.Audio) (string, error) { return s.sttText, s.sttErr }
|
||||
func (s *stubVoice) TTS(_ context.Context, _ string) (voice.Audio, error) { return s.tts, s.ttsErr }
|
||||
func (s *stubVoice) STT(_ context.Context, _ voice.Audio) (string, error) { return s.sttText, s.sttErr }
|
||||
func (s *stubVoice) TTS(_ context.Context, _ string) (voice.Audio, error) { return s.tts, s.ttsErr }
|
||||
func (s *stubVoice) CloneVoice(_ context.Context, _, _ string) (string, error) { return "", nil }
|
||||
|
||||
func TestHubVoiceInputTurn(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -390,6 +390,7 @@ func (s *Server) Handler() http.Handler {
|
||||
// fetch synthesized speech, bypassing the 4KB WebSocket frame limit.
|
||||
mux.Handle("POST /api/v1/audio/transcribe", s.auth.Authorize(auth.RoleOwner)(http.HandlerFunc(s.handleTranscribe)))
|
||||
mux.Handle("POST /api/v1/audio/speech", s.auth.Authorize(auth.RoleOwner)(http.HandlerFunc(s.handleSpeech)))
|
||||
mux.Handle("POST /api/v1/audio/voice-clone", s.auth.Authorize(auth.RoleOwner)(http.HandlerFunc(s.handleVoiceClone)))
|
||||
|
||||
// Session/model reads and writes are the owner's private history; owner/admin only
|
||||
// (chat uses the same roles). Session metadata in GET /sessions stays readAuth for
|
||||
|
||||
@@ -19,8 +19,13 @@ import (
|
||||
type VoiceService interface {
|
||||
// STT transcribes audio to text.
|
||||
STT(ctx context.Context, audio voice.Audio) (string, error)
|
||||
// TTS synthesizes audio from text.
|
||||
// TTS synthesizes audio from text. A per-request emotion/tone instruction may
|
||||
// be threaded through ctx (voice.WithTTSInstruction) to override the configured
|
||||
// VOICE_TTS_INSTRUCTION for one call.
|
||||
TTS(ctx context.Context, text string) (voice.Audio, error)
|
||||
// CloneVoice creates a custom voice (声音复刻 create_voice) from a public
|
||||
// reference audio URL and returns its voice_id.
|
||||
CloneVoice(ctx context.Context, referenceURL, prefix string) (string, error)
|
||||
}
|
||||
|
||||
// voiceInputRequest is the decoded payload of a voice_input frame. Audio is a
|
||||
|
||||
Reference in new issue
Block a user