Files
Cyrene_v2/internal/runtime/multimodal/voice/voice_clone_test.go
T
Aska 03d7050e1e
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(voice): 百炼声音复刻create_voice + TTS instruction情绪驱动(主TTS-昔涟复刻音色)
2026-09-08 06:48:15 +08:00

240 lines
9.0 KiB
Go

package voice
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// TestDashscopeCloner_Ok asserts the create_voice request body shape
// (model/action/target_model/prefix/url) and the voice_id parsing.
func TestDashscopeCloner_Ok(t *testing.T) {
t.Parallel()
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasSuffix(r.URL.Path, "/customization") {
t.Errorf("path = %q, want .../customization", r.URL.Path)
}
if r.Header.Get("Authorization") != "Bearer sk" {
t.Errorf("auth = %q, want Bearer sk", r.Header.Get("Authorization"))
}
if sse := r.Header.Get("X-DashScope-SSE"); sse != "" {
t.Errorf("sse = %q, want empty", sse)
}
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Fatalf("decode: %v", err)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"output":{"voice_id":"cosyvoice-v3-flash-cyrene-abc123"}}`))
}))
defer srv.Close()
cloner := NewDashscopeCloner(config.VoiceConfig{DashscopeBaseURL: srv.URL, DashscopeAPIKey: "sk"})
voiceID, err := cloner.CloneVoice(context.Background(), "https://example.com/ref.wav", "cyrene")
if err != nil {
t.Fatalf("CloneVoice error: %v", err)
}
if voiceID != "cosyvoice-v3-flash-cyrene-abc123" {
t.Errorf("voice_id = %q, want cosyvoice-v3-flash-cyrene-abc123", voiceID)
}
if gotBody["model"] != "voice-enrollment" {
t.Errorf("model = %v, want voice-enrollment", gotBody["model"])
}
input := gotBody["input"].(map[string]any)
if input["action"] != "create_voice" {
t.Errorf("action = %v, want create_voice", input["action"])
}
if input["target_model"] != "cosyvoice-v3-flash" {
t.Errorf("target_model = %v, want cosyvoice-v3-flash", input["target_model"])
}
if input["prefix"] != "cyrene" {
t.Errorf("prefix = %v, want cyrene", input["prefix"])
}
if input["url"] != "https://example.com/ref.wav" {
t.Errorf("url = %v, want the reference url", input["url"])
}
}
// TestDashscopeCloner_DefaultPrefix asserts the empty prefix falls back to the
// configured VOICE_CLONE_PREFIX default ("cyrene").
func TestDashscopeCloner_DefaultPrefix(t *testing.T) {
t.Parallel()
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Fatalf("decode: %v", err)
}
_, _ = w.Write([]byte(`{"output":{"voice_id":"v1"}}`))
}))
defer srv.Close()
cloner := NewDashscopeCloner(config.VoiceConfig{DashscopeBaseURL: srv.URL, DashscopeAPIKey: "sk", VoiceClonePrefix: "cyrene"})
if _, err := cloner.CloneVoice(context.Background(), "https://example.com/ref.wav", ""); err != nil {
t.Fatalf("CloneVoice error: %v", err)
}
if p := gotBody["input"].(map[string]any)["prefix"]; p != "cyrene" {
t.Errorf("prefix = %v, want default cyrene", p)
}
}
// TestDashscopeCloner_TargetModelOverride asserts target_model tracks the config
// TTSModel (must match the synthesis model).
func TestDashscopeCloner_TargetModelOverride(t *testing.T) {
t.Parallel()
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Fatalf("decode: %v", err)
}
_, _ = w.Write([]byte(`{"output":{"voice_id":"v1"}}`))
}))
defer srv.Close()
cloner := NewDashscopeCloner(config.VoiceConfig{DashscopeBaseURL: srv.URL, DashscopeAPIKey: "sk", TTSModel: "cosyvoice-v3.5-plus"})
if _, err := cloner.CloneVoice(context.Background(), "https://example.com/ref.wav", "x"); err != nil {
t.Fatalf("CloneVoice error: %v", err)
}
if tm := gotBody["input"].(map[string]any)["target_model"]; tm != "cosyvoice-v3.5-plus" {
t.Errorf("target_model = %v, want cosyvoice-v3.5-plus", tm)
}
}
// TestDashscopeCloner_NotConfigured asserts a missing API key degrades.
func TestDashscopeCloner_NotConfigured(t *testing.T) {
t.Parallel()
cloner := NewDashscopeCloner(config.VoiceConfig{})
if _, err := cloner.CloneVoice(context.Background(), "https://example.com/ref.wav", "x"); err != ErrNotConfigured {
t.Fatalf("err = %v, want ErrNotConfigured", err)
}
}
// TestDashscopeCloner_EmptyURL asserts an empty reference URL is rejected.
func TestDashscopeCloner_EmptyURL(t *testing.T) {
t.Parallel()
cloner := NewDashscopeCloner(config.VoiceConfig{DashscopeBaseURL: "http://x", DashscopeAPIKey: "sk"})
if _, err := cloner.CloneVoice(context.Background(), " ", "x"); !errors.Is(err, ErrProviderFailed) {
t.Fatalf("err = %v, want ErrProviderFailed", err)
}
}
// TestDashscopeCloner_BadPrefix asserts a non-alphanumeric or >10-char prefix is
// rejected before any request is sent.
func TestDashscopeCloner_BadPrefix(t *testing.T) {
t.Parallel()
cloner := NewDashscopeCloner(config.VoiceConfig{DashscopeBaseURL: "http://x", DashscopeAPIKey: "sk"})
for _, prefix := range []string{"invalid-prefix!", "this-prefix-is-way-too-long"} {
if _, err := cloner.CloneVoice(context.Background(), "https://example.com/ref.wav", prefix); !errors.Is(err, ErrProviderFailed) {
t.Errorf("prefix %q: err = %v, want ErrProviderFailed", prefix, err)
}
}
}
// TestDashscopeCloner_ModelError asserts a top-level message in an HTTP 200
// response is surfaced as an error (no voice_id).
func TestDashscopeCloner_ModelError(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"message":"invalid reference url"}`))
}))
defer srv.Close()
cloner := NewDashscopeCloner(config.VoiceConfig{DashscopeBaseURL: srv.URL, DashscopeAPIKey: "sk"})
if _, err := cloner.CloneVoice(context.Background(), "https://example.com/ref.wav", "x"); !errors.Is(err, ErrProviderFailed) {
t.Fatalf("err = %v, want ErrProviderFailed", err)
}
}
// TestDashscopeTTS_Instruction_ClonedVoice asserts a cloned voice carries the
// configured instruction in the request input.
func TestDashscopeTTS_Instruction_ClonedVoice(t *testing.T) {
t.Parallel()
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Fatalf("decode: %v", err)
}
_, _ = w.Write([]byte(`{"output":{"audio":{"data":"eA=="}}}`))
}))
defer srv.Close()
tts := NewDashscopeTTS(config.VoiceConfig{
DashscopeBaseURL: srv.URL,
DashscopeAPIKey: "sk",
TTSVoiceID: "cosyvoice-v3-flash-cyrene-abc123",
VoiceTTSInstruction: "语气开朗俏皮",
})
if _, err := tts.TTS(context.Background(), "你好"); err != nil {
t.Fatalf("TTS error: %v", err)
}
if gotBody["model"] != "cosyvoice-v3-flash" {
t.Errorf("model = %v, want cosyvoice-v3-flash", gotBody["model"])
}
input := gotBody["input"].(map[string]any)
if input["instruction"] != "语气开朗俏皮" {
t.Errorf("instruction = %v, want the configured instruction", input["instruction"])
}
}
// TestDashscopeTTS_NoInstruction_SystemVoice asserts a system voice (longxiaochun_v3)
// never receives a free-form instruction even when one is configured.
func TestDashscopeTTS_NoInstruction_SystemVoice(t *testing.T) {
t.Parallel()
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Fatalf("decode: %v", err)
}
_, _ = w.Write([]byte(`{"output":{"audio":{"data":"eA=="}}}`))
}))
defer srv.Close()
tts := NewDashscopeTTS(config.VoiceConfig{
DashscopeBaseURL: srv.URL,
DashscopeAPIKey: "sk",
TTSVoiceID: "longxiaochun_v3",
VoiceTTSInstruction: "语气开朗俏皮",
})
if _, err := tts.TTS(context.Background(), "你好"); err != nil {
t.Fatalf("TTS error: %v", err)
}
input := gotBody["input"].(map[string]any)
if _, ok := input["instruction"]; ok {
t.Errorf("instruction present for system voice, want omitted: %v", input)
}
}
// TestDashscopeTTS_Instruction_ContextOverride asserts a per-request ctx
// instruction wins over the configured one for a cloned voice.
func TestDashscopeTTS_Instruction_ContextOverride(t *testing.T) {
t.Parallel()
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Fatalf("decode: %v", err)
}
_, _ = w.Write([]byte(`{"output":{"audio":{"data":"eA=="}}}`))
}))
defer srv.Close()
tts := NewDashscopeTTS(config.VoiceConfig{
DashscopeBaseURL: srv.URL,
DashscopeAPIKey: "sk",
TTSVoiceID: "cosyvoice-v3-flash-cyrene-abc123",
VoiceTTSInstruction: "语气温柔",
})
ctx := WithTTSInstruction(context.Background(), "语气开朗俏皮")
if _, err := tts.TTS(ctx, "你好"); err != nil {
t.Fatalf("TTS error: %v", err)
}
if instr := gotBody["input"].(map[string]any)["instruction"]; instr != "语气开朗俏皮" {
t.Errorf("instruction = %v, want the ctx override", instr)
}
}