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
@@ -12,7 +12,8 @@ import (
|
||||
// provider is unavailable (nil direction) or fails. It implements STT and TTS so
|
||||
// it can be used anywhere a single provider is expected.
|
||||
type Chain struct {
|
||||
steps []Provider
|
||||
steps []Provider
|
||||
cloner VoiceCloner
|
||||
}
|
||||
|
||||
// NewChain builds a degrading chain from the given providers in the order they
|
||||
@@ -42,6 +43,16 @@ func (c *Chain) STT(ctx context.Context, audio Audio) (string, error) {
|
||||
return "", fmt.Errorf("voice: %w: %v", ErrProviderFailed, lastErr)
|
||||
}
|
||||
|
||||
// CloneVoice creates a custom voice via the cloud provider's voice-enrollment
|
||||
// endpoint (声音复刻 create_voice). A chain without a cloud rung returns
|
||||
// ErrNotConfigured. It is not a degradation step — the caller gets one result.
|
||||
func (c *Chain) CloneVoice(ctx context.Context, referenceURL, prefix string) (string, error) {
|
||||
if c.cloner == nil {
|
||||
return "", ErrNotConfigured
|
||||
}
|
||||
return c.cloner.CloneVoice(ctx, referenceURL, prefix)
|
||||
}
|
||||
|
||||
// TTS walks the chain for synthesized audio.
|
||||
func (c *Chain) TTS(ctx context.Context, text string) (Audio, error) {
|
||||
if len(c.steps) == 0 {
|
||||
@@ -98,8 +109,10 @@ func New(cfg config.VoiceConfig, opts ...Option) *Chain {
|
||||
}
|
||||
|
||||
steps := make([]Provider, 0, 5)
|
||||
var cloner VoiceCloner
|
||||
if voiceConfigured(cfg) {
|
||||
steps = append(steps, CloudProvider(newDashboardSTT(cfg), newDashboardTTS(cfg)))
|
||||
cloner = newDashboardCloner(cfg)
|
||||
}
|
||||
switch cfg.TTSProvider {
|
||||
case "edge-tts", "edge":
|
||||
@@ -108,7 +121,9 @@ func New(cfg config.VoiceConfig, opts ...Option) *Chain {
|
||||
steps = append(steps, EspeakProvider(newEspeak()))
|
||||
}
|
||||
steps = append(steps, SilentProvider())
|
||||
return NewChain(steps...)
|
||||
chain := NewChain(steps...)
|
||||
chain.cloner = cloner
|
||||
return chain
|
||||
}
|
||||
|
||||
// voiceConfigured reports whether any cloud voice credential or model is set, so
|
||||
|
||||
@@ -212,6 +212,12 @@ func newDashboardTTS(cfg config.VoiceConfig) TTS {
|
||||
return NewDashscopeTTS(cfg)
|
||||
}
|
||||
|
||||
// newDashboardCloner returns the cloud voice-enrollment cloner for a DashScope
|
||||
// config, used by the create_voice (声音复刻) endpoint.
|
||||
func newDashboardCloner(cfg config.VoiceConfig) VoiceCloner {
|
||||
return NewDashscopeCloner(cfg)
|
||||
}
|
||||
|
||||
// newEdgeTTS returns the edge-tts adapter point (not yet implemented).
|
||||
func newEdgeTTS() TTS { return notImplementedTTS{} }
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
)
|
||||
@@ -33,8 +34,14 @@ const (
|
||||
dashscopeSTTPath = "/services/aigc/multimodal-generation/generation"
|
||||
// dashscopeTTSPath is the SpeechSynthesizer service path (TTS).
|
||||
dashscopeTTSPath = "/services/audio/tts/SpeechSynthesizer"
|
||||
// dashscopeClonePath is the voice-customization (声音复刻 create_voice) path.
|
||||
dashscopeClonePath = "/services/audio/tts/customization"
|
||||
// defaultDashscopeBaseURL is the Bailian API root when none is configured.
|
||||
defaultDashscopeBaseURL = "https://dashscope.aliyuncs.com/api/v1"
|
||||
// defaultClonePrefix is the voice-name prefix when none is supplied.
|
||||
defaultClonePrefix = "cyrene"
|
||||
// voiceEnrollmentModel is the DashScope voice-enrollment model ("create_voice").
|
||||
voiceEnrollmentModel = "voice-enrollment"
|
||||
)
|
||||
|
||||
// DashscopeSTT transcribes audio via DashScope multimodal-generation.
|
||||
@@ -114,13 +121,14 @@ func (s *DashscopeSTT) STT(ctx context.Context, audio Audio) (string, error) {
|
||||
|
||||
// DashscopeTTS synthesizes speech video via DashScope SpeechSynthesizer.
|
||||
type DashscopeTTS struct {
|
||||
endpoint string
|
||||
apiKey string
|
||||
model string
|
||||
voiceID string
|
||||
format string
|
||||
sampleRate int
|
||||
client *http.Client
|
||||
endpoint string
|
||||
apiKey string
|
||||
model string
|
||||
voiceID string
|
||||
format string
|
||||
sampleRate int
|
||||
instruction string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewDashscopeTTS builds the DashScope TTS adapter from cfg. Missing model /
|
||||
@@ -134,13 +142,14 @@ func NewDashscopeTTS(cfg config.VoiceConfig) *DashscopeTTS {
|
||||
sampleRate = 24000
|
||||
}
|
||||
return &DashscopeTTS{
|
||||
endpoint: dashscopeEndpoint(cfg.DashscopeBaseURL) + dashscopeTTSPath,
|
||||
apiKey: cfg.DashscopeAPIKey,
|
||||
model: model,
|
||||
voiceID: voiceID,
|
||||
format: format,
|
||||
sampleRate: sampleRate,
|
||||
client: defaultHTTPClient(),
|
||||
endpoint: dashscopeEndpoint(cfg.DashscopeBaseURL) + dashscopeTTSPath,
|
||||
apiKey: cfg.DashscopeAPIKey,
|
||||
model: model,
|
||||
voiceID: voiceID,
|
||||
format: format,
|
||||
sampleRate: sampleRate,
|
||||
instruction: cfg.VoiceTTSInstruction,
|
||||
client: defaultHTTPClient(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,7 +166,7 @@ func (t *DashscopeTTS) TTS(ctx context.Context, text string) (Audio, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Audio{}, err
|
||||
}
|
||||
payload, err := json.Marshal(dashscopeTTSRequest{
|
||||
req := dashscopeTTSRequest{
|
||||
Model: t.model,
|
||||
Input: dashscopeTTSInput{
|
||||
Text: text,
|
||||
@@ -165,7 +174,11 @@ func (t *DashscopeTTS) TTS(ctx context.Context, text string) (Audio, error) {
|
||||
Format: t.format,
|
||||
SampleRate: t.sampleRate,
|
||||
},
|
||||
})
|
||||
}
|
||||
if instruction := t.EffectiveInstruction(ctx); instruction != "" {
|
||||
req.Input.Instruction = instruction
|
||||
}
|
||||
payload, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return Audio{}, fmt.Errorf("voice: %w: marshal: %v", ErrProviderFailed, err)
|
||||
}
|
||||
@@ -181,6 +194,29 @@ func (t *DashscopeTTS) TTS(ctx context.Context, text string) (Audio, error) {
|
||||
return Audio{Format: t.format, Data: data}, nil
|
||||
}
|
||||
|
||||
// EffectiveInstruction returns the instruction to send for this synthesis: a
|
||||
// per-request override threaded through ctx wins, else the configured default. An
|
||||
// empty result means the request carries no instruction. A system voice never gets
|
||||
// a free-form instruction (see canInstruct), because cosyvoice v3 system voices
|
||||
// require a fixed instruction format.
|
||||
func (t *DashscopeTTS) EffectiveInstruction(ctx context.Context) string {
|
||||
if !canInstruct(t.voiceID) {
|
||||
return ""
|
||||
}
|
||||
if override := TTSInstructionFromContext(ctx); override != "" {
|
||||
return override
|
||||
}
|
||||
return t.instruction
|
||||
}
|
||||
|
||||
// canInstruct reports whether voiceID accepts a free-form instruction. cosyvoice v3
|
||||
// system voices (the long*_v3 family, e.g. longxiaochun_v3) require a fixed
|
||||
// instruction format; a cloned/designed voice (声音复刻, whose id never ends in
|
||||
// "_v3") accepts an arbitrary instruction.
|
||||
func canInstruct(voiceID string) bool {
|
||||
return !strings.HasSuffix(voiceID, "_v3")
|
||||
}
|
||||
|
||||
// audioBytes resolves the synthesized audio from the DashScope response: a 24h
|
||||
// URL is downloaded, or an inline base64 blob is decoded.
|
||||
func (t *DashscopeTTS) audioBytes(ctx context.Context, body []byte) ([]byte, error) {
|
||||
@@ -227,6 +263,95 @@ func (t *DashscopeTTS) download(ctx context.Context, url string) ([]byte, error)
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// DashscopeCloner creates custom TTS voices via the DashScope voice-enrollment
|
||||
// API (声音复刻 create_voice). The reference audio must be a public URL — the
|
||||
// create_voice action only accepts input.url (no base64). The returned voice_id is
|
||||
// bound to TTSModel, so target_model must match the model used at synthesis time.
|
||||
type DashscopeCloner struct {
|
||||
endpoint string
|
||||
apiKey string
|
||||
model string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewDashscopeCloner builds the DashScope voice cloner from cfg. Missing model /
|
||||
// base URL fall back to the DashScope defaults; the model tracks TTSModel so the
|
||||
// cloned voice is usable by the TTS rung (target_model must match).
|
||||
func NewDashscopeCloner(cfg config.VoiceConfig) *DashscopeCloner {
|
||||
return &DashscopeCloner{
|
||||
endpoint: dashscopeEndpoint(cfg.DashscopeBaseURL) + dashscopeClonePath,
|
||||
apiKey: cfg.DashscopeAPIKey,
|
||||
model: firstNonEmpty(cfg.TTSModel, "cosyvoice-v3-flash"),
|
||||
client: defaultHTTPClient(),
|
||||
}
|
||||
}
|
||||
|
||||
// CloneVoice submits a create_voice request and returns the created voice_id. It
|
||||
// is the adapter behind the REST /api/v1/audio/voice-clone endpoint. A missing API
|
||||
// key returns ErrNotConfigured; an empty URL or an invalid prefix is rejected.
|
||||
func (d *DashscopeCloner) CloneVoice(ctx context.Context, referenceURL, prefix string) (string, error) {
|
||||
if d.apiKey == "" {
|
||||
return "", ErrNotConfigured
|
||||
}
|
||||
referenceURL = strings.TrimSpace(referenceURL)
|
||||
if referenceURL == "" {
|
||||
return "", fmt.Errorf("voice: %w: reference audio url is required", ErrProviderFailed)
|
||||
}
|
||||
prefix, err := normalizeClonePrefix(prefix)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
payload, err := json.Marshal(dashscopeCloneRequest{
|
||||
Model: voiceEnrollmentModel,
|
||||
Input: dashscopeCloneInput{
|
||||
Action: "create_voice",
|
||||
TargetModel: d.model,
|
||||
Prefix: prefix,
|
||||
URL: referenceURL,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("voice: %w: marshal: %v", ErrProviderFailed, err)
|
||||
}
|
||||
body, err := dashscopePost(ctx, d.client, d.apiKey, d.endpoint, payload, "")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var resp dashscopeCloneResponse
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return "", fmt.Errorf("voice: %w: decode: %v", ErrProviderFailed, err)
|
||||
}
|
||||
if resp.Message != "" {
|
||||
return "", fmt.Errorf("voice: %w: %s", ErrProviderFailed, resp.Message)
|
||||
}
|
||||
if resp.Output.VoiceID == "" {
|
||||
return "", fmt.Errorf("voice: %w: no voice_id in response", ErrProviderFailed)
|
||||
}
|
||||
return resp.Output.VoiceID, nil
|
||||
}
|
||||
|
||||
// normalizeClonePrefix trims and validates the voice-name prefix. An empty value
|
||||
// falls back to defaultClonePrefix; the result must be ≤10 letter/digit chars
|
||||
// (the voice-enrollment prefix constraint).
|
||||
func normalizeClonePrefix(prefix string) (string, error) {
|
||||
prefix = strings.TrimSpace(prefix)
|
||||
if prefix == "" {
|
||||
return defaultClonePrefix, nil
|
||||
}
|
||||
if len(prefix) > 10 {
|
||||
return "", fmt.Errorf("voice: %w: prefix %q exceeds 10 chars", ErrProviderFailed, prefix)
|
||||
}
|
||||
for _, r := range prefix {
|
||||
if !unicode.IsLetter(r) && !unicode.IsDigit(r) {
|
||||
return "", fmt.Errorf("voice: %w: prefix must be alphanumeric", ErrProviderFailed)
|
||||
}
|
||||
}
|
||||
return prefix, nil
|
||||
}
|
||||
|
||||
// dashscopeEndpoint returns the DashScope API base with a trailing slash trimmed,
|
||||
// defaulting to the public Bailian root when none is configured.
|
||||
func dashscopeEndpoint(baseURL string) string {
|
||||
@@ -394,4 +519,30 @@ type dashscopeTTSInput struct {
|
||||
Voice string `json:"voice"`
|
||||
Format string `json:"format"`
|
||||
SampleRate int `json:"sample_rate"`
|
||||
// Instruction is the CosyVoice emotion/tone instruction; omitempty keeps a
|
||||
// system voice (which must use a fixed instruction) free of a free-form one.
|
||||
Instruction string `json:"instruction,omitempty"`
|
||||
}
|
||||
|
||||
// dashscopeCloneRequest is the voice-enrollment create_voice request body.
|
||||
type dashscopeCloneRequest struct {
|
||||
Model string `json:"model"`
|
||||
Input dashscopeCloneInput `json:"input"`
|
||||
}
|
||||
|
||||
// dashscopeCloneInput carries the create_voice action parameters.
|
||||
type dashscopeCloneInput struct {
|
||||
Action string `json:"action"`
|
||||
TargetModel string `json:"target_model"`
|
||||
Prefix string `json:"prefix"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// dashscopeCloneResponse is the voice-enrollment response envelope. A top-level
|
||||
// message marks a model-level error surfaced even on an HTTP 200.
|
||||
type dashscopeCloneResponse struct {
|
||||
Output struct {
|
||||
VoiceID string `json:"voice_id"`
|
||||
} `json:"output"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
@@ -126,6 +126,12 @@ func (s *Service) TTS(ctx context.Context, text string) (Audio, error) {
|
||||
return res.audio, res.err
|
||||
}
|
||||
|
||||
// CloneVoice creates a custom voice, delegating to the underlying chain. It is a
|
||||
// one-shot admin operation (not a worker-pool job), so it runs directly.
|
||||
func (s *Service) CloneVoice(ctx context.Context, referenceURL, prefix string) (string, error) {
|
||||
return s.chain.CloneVoice(ctx, referenceURL, prefix)
|
||||
}
|
||||
|
||||
// submit enqueues j and blocks until the result or a cancellation.
|
||||
func (s *Service) submit(ctx context.Context, j job) (jobResult, error) {
|
||||
select {
|
||||
|
||||
@@ -33,3 +33,31 @@ type TTS interface {
|
||||
// honored.
|
||||
TTS(ctx context.Context, text string) (Audio, error)
|
||||
}
|
||||
|
||||
// VoiceCloner creates a custom voice (声音复刻) from a public reference audio URL.
|
||||
// A nil-cloner provider (no cloud rung configured) returns ErrNotConfigured.
|
||||
type VoiceCloner interface {
|
||||
// CloneVoice creates a custom voice and returns its voice_id. referenceURL is
|
||||
// a public audio URL; prefix is the voice-name prefix (≤10 alphanumeric chars).
|
||||
CloneVoice(ctx context.Context, referenceURL, prefix string) (string, error)
|
||||
}
|
||||
|
||||
// ctxKeyTTSInstruction is the context key for a per-request TTS instruction.
|
||||
// The TTS interface only carries (ctx, text), so an optional instruction is
|
||||
// threaded through ctx to reach the adapter without widening the interface.
|
||||
type ctxKeyTTSInstruction struct{}
|
||||
|
||||
// WithTTSInstruction returns ctx carrying an optional per-request TTS instruction
|
||||
// (emotion/tone) that overrides the configured VOICE_TTS_INSTRUCTION for one call.
|
||||
func WithTTSInstruction(ctx context.Context, instruction string) context.Context {
|
||||
return context.WithValue(ctx, ctxKeyTTSInstruction{}, instruction)
|
||||
}
|
||||
|
||||
// TTSInstructionFromContext returns the per-request TTS instruction set by
|
||||
// WithTTSInstruction, or "" when none is carried.
|
||||
func TTSInstructionFromContext(ctx context.Context) string {
|
||||
if v, ok := ctx.Value(ctxKeyTTSInstruction{}).(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user