feat: 支持 MiniMax 音色克隆和 2.8 语音模型
This commit is contained in:
@@ -130,6 +130,21 @@ func (s *Service) billings(ctx context.Context, user *auth.User, kind string, bo
|
||||
amount := float64(quantity) * resourcePrice(config, resource, baseKey, "basePrice") * discount
|
||||
return []any{billingLine(candidate, resource, unit, quantity, roundPrice(amount), discount, simulated)}
|
||||
}
|
||||
if kind == "voice.clone" {
|
||||
text := stringFromMap(body, "text")
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return nil
|
||||
}
|
||||
resource = "audio"
|
||||
unit = "character"
|
||||
baseKey = "audioBase"
|
||||
quantity := len([]rune(text))
|
||||
if quantity <= 0 {
|
||||
quantity = 1
|
||||
}
|
||||
amount := float64(quantity) * resourcePrice(config, resource, baseKey, "basePrice") * discount
|
||||
return []any{billingLineWithDetails(candidate, resource, unit, quantity, roundPrice(amount), discount, simulated, map[string]any{"preview": true})}
|
||||
}
|
||||
amount := float64(count) * resourcePrice(config, resource, baseKey, "basePrice") * resourceWeight(config, resource, "qualityWeights", stringFromMap(body, "quality")) * resourceWeight(config, resource, "sizeWeights", stringFromMap(body, "size")) * resourceWeight(config, resource, "resolutionWeights", firstNonEmptyString(stringFromMap(body, "resolution"), stringFromMap(body, "size"))) * discount
|
||||
return []any{billingLine(candidate, resource, unit, count, roundPrice(amount), discount, simulated)}
|
||||
}
|
||||
|
||||
@@ -313,6 +313,9 @@ func requestAssetHydrationForField(path []string, asset store.RequestAsset, cand
|
||||
if providerFieldNeedsRawBase64(path) {
|
||||
return requestAssetHydrateRawBase64
|
||||
}
|
||||
if candidate.ModelType == "voice_clone" && voiceCloneAudioFieldNeedsHydration(path, asset) {
|
||||
return requestAssetHydrateDataURL
|
||||
}
|
||||
if requestAssetMediaKindForHydration(path, asset) == "image" {
|
||||
if style, ok := requestAssetCapabilityHydrationForMedia("image", candidate, asset.URL, asset.StorageProvider); ok {
|
||||
return style
|
||||
@@ -333,12 +336,27 @@ func requestAssetMediaKindForHydration(path []string, asset store.RequestAsset)
|
||||
if mediaURLFieldNeedsHydration(path) {
|
||||
return requestAssetMediaURLKind(path)
|
||||
}
|
||||
if voiceCloneAudioFieldNeedsHydration(path, asset) {
|
||||
return "audio"
|
||||
}
|
||||
if imageInputFieldNeedsHydration(path) {
|
||||
return "image"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func voiceCloneAudioFieldNeedsHydration(path []string, asset store.RequestAsset) bool {
|
||||
key, parent := requestAssetFieldPath(path)
|
||||
switch key {
|
||||
case "audio", "file", "source_audio", "sourceaudio", "prompt_audio", "promptaudio", "audio_url", "audiourl", "prompt_audio_url", "promptaudiourl":
|
||||
return true
|
||||
case "url":
|
||||
return parent == "audio_url" || parent == "audiourl" || parent == "prompt_audio_url" || parent == "promptaudiourl"
|
||||
}
|
||||
contentType := strings.ToLower(strings.TrimSpace(asset.ContentType))
|
||||
return strings.HasPrefix(contentType, "audio/")
|
||||
}
|
||||
|
||||
func requestAssetCapabilityHydrationForMedia(kind string, candidate store.RuntimeModelCandidate, urlValue string, storageProvider string) (requestAssetHydrationStyle, bool) {
|
||||
if kind != "image" {
|
||||
return "", false
|
||||
|
||||
@@ -120,6 +120,31 @@ func (s *Service) execute(ctx context.Context, task store.GatewayTask, user *aut
|
||||
}
|
||||
return Result{Task: failed, Output: failed.Result}, err
|
||||
}
|
||||
var clonedVoice clonedVoiceBinding
|
||||
body, clonedVoice, err = s.resolveClonedVoiceBinding(ctx, user, task.Kind, body)
|
||||
if err != nil {
|
||||
s.recordFailedAttempt(ctx, failedAttemptRecord{
|
||||
Task: task,
|
||||
Body: body,
|
||||
AttemptNo: task.AttemptCount + 1,
|
||||
Code: clients.ErrorCode(err),
|
||||
Cause: err,
|
||||
Simulated: task.RunMode == "simulation",
|
||||
Scope: "cloned_voice_binding",
|
||||
Reason: "cloned_voice_binding_failed",
|
||||
ModelType: modelType,
|
||||
})
|
||||
failed, finishErr := s.failTask(ctx, task.ID, clients.ErrorCode(err), err.Error(), task.RunMode == "simulation", err)
|
||||
if finishErr != nil {
|
||||
return Result{}, finishErr
|
||||
}
|
||||
return Result{Task: failed, Output: failed.Result}, err
|
||||
}
|
||||
if clonedVoice.Found {
|
||||
if err := s.store.MarkTaskRunning(ctx, task.ID, modelType, s.slimTaskRequestSnapshot(task, body)); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
}
|
||||
candidates, err := s.store.ListModelCandidates(ctx, task.Model, modelType, user)
|
||||
if err != nil {
|
||||
s.recordFailedAttempt(ctx, failedAttemptRecord{
|
||||
@@ -139,6 +164,25 @@ func (s *Service) execute(ctx context.Context, task store.GatewayTask, user *aut
|
||||
}
|
||||
return Result{Task: failed, Output: failed.Result}, err
|
||||
}
|
||||
candidates, err = filterCandidatesByClonedVoiceBinding(candidates, clonedVoice)
|
||||
if err != nil {
|
||||
s.recordFailedAttempt(ctx, failedAttemptRecord{
|
||||
Task: task,
|
||||
Body: body,
|
||||
AttemptNo: task.AttemptCount + 1,
|
||||
Code: store.ModelCandidateErrorCode(err),
|
||||
Cause: err,
|
||||
Simulated: task.RunMode == "simulation",
|
||||
Scope: "cloned_voice_binding",
|
||||
Reason: store.ModelCandidateErrorCode(err),
|
||||
ModelType: modelType,
|
||||
})
|
||||
failed, finishErr := s.failTask(ctx, task.ID, store.ModelCandidateErrorCode(err), err.Error(), task.RunMode == "simulation", err)
|
||||
if finishErr != nil {
|
||||
return Result{}, finishErr
|
||||
}
|
||||
return Result{Task: failed, Output: failed.Result}, err
|
||||
}
|
||||
var candidateFilterSummary map[string]any
|
||||
candidates, candidateFilterSummary, err = filterRuntimeCandidatesByRequest(task.Kind, task.Model, modelType, body, candidates)
|
||||
if err != nil {
|
||||
@@ -666,6 +710,36 @@ func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user
|
||||
return clients.Response{}, err
|
||||
}
|
||||
response.Result = uploadedResult
|
||||
if task.Kind == "voice.clone" {
|
||||
voice, err := s.persistVoiceCloneResult(ctx, task, user, candidate, attemptID, body, response.Result)
|
||||
if err != nil {
|
||||
metrics := mergeMetrics(taskMetrics(task, user, body, candidate, response, simulated), parameterPreprocessingMetrics(preprocessing), map[string]any{
|
||||
"error": err.Error(),
|
||||
"retryable": false,
|
||||
"trace": []any{failureTraceEntry(err, false)},
|
||||
})
|
||||
_ = s.store.FinishTaskAttempt(ctx, store.FinishTaskAttemptInput{
|
||||
AttemptID: attemptID,
|
||||
Status: "failed",
|
||||
Retryable: false,
|
||||
RequestID: response.RequestID,
|
||||
Usage: usageToMap(response.Usage),
|
||||
Metrics: metrics,
|
||||
ResponseSnapshot: response.Result,
|
||||
ResponseStartedAt: response.ResponseStartedAt,
|
||||
ResponseFinishedAt: response.ResponseFinishedAt,
|
||||
ResponseDurationMS: response.ResponseDurationMS,
|
||||
ErrorCode: "cloned_voice_persist_failed",
|
||||
ErrorMessage: err.Error(),
|
||||
})
|
||||
return clients.Response{}, err
|
||||
}
|
||||
response.Result["cloned_voice"] = voice
|
||||
response.Result["clonedVoice"] = voice
|
||||
}
|
||||
if task.Kind == "speech.generations" {
|
||||
s.touchClonedVoiceUsage(ctx, user, body, candidate)
|
||||
}
|
||||
response.Result = s.enrichGeneratedVideoMetadata(ctx, task.Kind, response.Result)
|
||||
for _, progress := range response.Progress {
|
||||
if err := s.emit(ctx, task.ID, "task.progress", "running", progress.Phase, progress.Progress, progress.Message, progress.Payload, simulated); err != nil {
|
||||
@@ -963,6 +1037,8 @@ func modelTypeFromKind(kind string, body map[string]any) string {
|
||||
return "audio_generate"
|
||||
case "speech.generations":
|
||||
return "text_to_speech"
|
||||
case "voice.clone":
|
||||
return "voice_clone"
|
||||
default:
|
||||
return "task"
|
||||
}
|
||||
@@ -989,6 +1065,8 @@ func canonicalModelType(value string) string {
|
||||
return "audio_generate"
|
||||
case "speech", "tts":
|
||||
return "text_to_speech"
|
||||
case "voice", "voice_clone", "voiceclone", "voice.cloning":
|
||||
return "voice_clone"
|
||||
default:
|
||||
return normalized
|
||||
}
|
||||
@@ -996,7 +1074,7 @@ func canonicalModelType(value string) string {
|
||||
|
||||
func isKnownModelType(value string) bool {
|
||||
switch value {
|
||||
case "text_generate", "text_embedding", "text_rerank", "image_generate", "image_edit", "video_generate", "image_to_video", "text_to_video", "video_edit", "video_reference", "video_first_last_frame", "omni_video", "omni", "audio_generate", "text_to_speech":
|
||||
case "text_generate", "text_embedding", "text_rerank", "image_generate", "image_edit", "video_generate", "image_to_video", "text_to_video", "video_edit", "video_reference", "video_first_last_frame", "omni_video", "omni", "audio_generate", "text_to_speech", "voice_clone":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -1228,6 +1306,10 @@ func validateRequest(kind string, body map[string]any) error {
|
||||
if strings.TrimSpace(stringFromMap(body, "voice_id")) == "" {
|
||||
return errors.New("voice_id is required")
|
||||
}
|
||||
case "voice.clone":
|
||||
if err := validateVoiceCloneRequest(body); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
type clonedVoiceBinding struct {
|
||||
Voice store.ClonedVoice
|
||||
Found bool
|
||||
Explicit bool
|
||||
}
|
||||
|
||||
func validateVoiceCloneRequest(body map[string]any) error {
|
||||
voiceID := firstNonEmptyString(stringFromMap(body, "voice_id"), stringFromMap(body, "voiceId"))
|
||||
if !validMiniMaxVoiceID(voiceID) {
|
||||
return fmt.Errorf("voice_id must be 8-256 chars, start with an English letter, contain only letters, digits, '-' or '_', and not end with '-' or '_'")
|
||||
}
|
||||
if body["file_id"] == nil && body["fileId"] == nil &&
|
||||
stringFromAny(body["audio"]) == "" &&
|
||||
stringFromAny(body["file"]) == "" &&
|
||||
stringFromAny(body["source_audio"]) == "" &&
|
||||
stringFromAny(body["sourceAudio"]) == "" &&
|
||||
stringFromMap(body, "audio_url") == "" &&
|
||||
stringFromMap(body, "audioUrl") == "" {
|
||||
return fmt.Errorf("file_id or audio is required")
|
||||
}
|
||||
if hasVoiceClonePromptAudio(body) && firstNonEmptyString(stringFromMap(body, "prompt_text"), stringFromMap(body, "promptText")) == "" {
|
||||
return fmt.Errorf("prompt_text is required when prompt audio is provided")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validMiniMaxVoiceID(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) < 8 || len(value) > 256 {
|
||||
return false
|
||||
}
|
||||
for index, r := range value {
|
||||
if index == 0 && !isASCIILetter(r) {
|
||||
return false
|
||||
}
|
||||
if !(isASCIILetter(r) || unicode.IsDigit(r) || r == '-' || r == '_') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return !strings.HasSuffix(value, "-") && !strings.HasSuffix(value, "_")
|
||||
}
|
||||
|
||||
func isASCIILetter(r rune) bool {
|
||||
return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')
|
||||
}
|
||||
|
||||
func hasVoiceClonePromptAudio(body map[string]any) bool {
|
||||
return body["prompt_file_id"] != nil ||
|
||||
body["promptFileId"] != nil ||
|
||||
stringFromAny(body["prompt_audio"]) != "" ||
|
||||
stringFromAny(body["promptAudio"]) != "" ||
|
||||
stringFromMap(body, "prompt_audio_url") != "" ||
|
||||
stringFromMap(body, "promptAudioUrl") != ""
|
||||
}
|
||||
|
||||
func (s *Service) resolveClonedVoiceBinding(ctx context.Context, user *auth.User, kind string, body map[string]any) (map[string]any, clonedVoiceBinding, error) {
|
||||
if kind != "speech.generations" {
|
||||
return body, clonedVoiceBinding{}, nil
|
||||
}
|
||||
clonedVoiceID := firstNonEmptyString(stringFromMap(body, "cloned_voice_id"), stringFromMap(body, "clonedVoiceId"))
|
||||
voiceID := firstNonEmptyString(stringFromMap(body, "voice_id"), stringFromMap(body, "voiceId"))
|
||||
if clonedVoiceID == "" && voiceID == "" {
|
||||
return body, clonedVoiceBinding{}, nil
|
||||
}
|
||||
if clonedVoiceID != "" && !looksLikeUUID(clonedVoiceID) {
|
||||
return body, clonedVoiceBinding{}, &clients.ClientError{Code: "bad_request", Message: "cloned_voice_id must be a UUID", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
voice, found, err := s.store.FindClonedVoiceForUser(ctx, user, clonedVoiceID, voiceID)
|
||||
if err != nil {
|
||||
return body, clonedVoiceBinding{}, err
|
||||
}
|
||||
if !found {
|
||||
if clonedVoiceID != "" {
|
||||
return body, clonedVoiceBinding{}, &clients.ClientError{Code: "cloned_voice_not_found", Message: "cloned voice not found", StatusCode: 404, Retryable: false}
|
||||
}
|
||||
return body, clonedVoiceBinding{}, nil
|
||||
}
|
||||
if strings.TrimSpace(voice.Status) != "" && voice.Status != "active" {
|
||||
return body, clonedVoiceBinding{}, &clients.ClientError{Code: "cloned_voice_unavailable", Message: "cloned voice is not active", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
if voice.ExpiresAt != "" {
|
||||
if expiresAt, err := time.Parse(time.RFC3339Nano, voice.ExpiresAt); err == nil && !expiresAt.After(time.Now()) {
|
||||
_ = s.store.MarkClonedVoiceStatus(context.WithoutCancel(ctx), voice.ID, "expired")
|
||||
return body, clonedVoiceBinding{}, &clients.ClientError{Code: "cloned_voice_expired", Message: "cloned voice has expired", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
}
|
||||
out := cloneMap(body)
|
||||
out["voice_id"] = voice.VoiceID
|
||||
out["cloned_voice_id"] = voice.ID
|
||||
return out, clonedVoiceBinding{Voice: voice, Found: true, Explicit: clonedVoiceID != ""}, nil
|
||||
}
|
||||
|
||||
func filterCandidatesByClonedVoiceBinding(candidates []store.RuntimeModelCandidate, binding clonedVoiceBinding) ([]store.RuntimeModelCandidate, error) {
|
||||
if !binding.Found {
|
||||
return candidates, nil
|
||||
}
|
||||
filtered := make([]store.RuntimeModelCandidate, 0, len(candidates))
|
||||
preferred := make([]store.RuntimeModelCandidate, 0, 1)
|
||||
for _, candidate := range candidates {
|
||||
if strings.TrimSpace(candidate.PlatformID) != binding.Voice.PlatformID {
|
||||
continue
|
||||
}
|
||||
if binding.Voice.PlatformModelID != "" && candidate.PlatformModelID == binding.Voice.PlatformModelID {
|
||||
preferred = append(preferred, candidate)
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, candidate)
|
||||
}
|
||||
if len(preferred) > 0 {
|
||||
filtered = append(preferred, filtered...)
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
return nil, &store.ModelCandidateUnavailableError{
|
||||
Code: "cloned_voice_platform_unavailable",
|
||||
Message: "cloned voice is bound to a platform that has no enabled candidate for the requested speech model",
|
||||
Details: map[string]any{
|
||||
"clonedVoiceId": binding.Voice.ID,
|
||||
"voiceId": binding.Voice.VoiceID,
|
||||
"platformId": binding.Voice.PlatformID,
|
||||
"platformModelId": binding.Voice.PlatformModelID,
|
||||
},
|
||||
}
|
||||
}
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
func (s *Service) persistVoiceCloneResult(ctx context.Context, task store.GatewayTask, user *auth.User, candidate store.RuntimeModelCandidate, attemptID string, body map[string]any, result map[string]any) (store.ClonedVoice, error) {
|
||||
voiceID := firstNonEmptyString(stringFromAny(result["voice_id"]), stringFromMap(body, "voice_id"), stringFromMap(body, "voiceId"))
|
||||
demoAudioURL := firstNonEmptyString(stringFromAny(result["demo_audio"]), firstAudioURLFromResult(result))
|
||||
previewModel := firstNonEmptyString(stringFromMap(body, "preview_model"), stringFromMap(body, "previewModel"), stringFromAny(result["preview_model"]))
|
||||
expiresAt := time.Now().Add(7 * 24 * time.Hour)
|
||||
return s.store.UpsertClonedVoice(ctx, store.ClonedVoiceInput{
|
||||
GatewayUserID: task.GatewayUserID,
|
||||
UserID: task.UserID,
|
||||
GatewayTenantID: task.GatewayTenantID,
|
||||
TenantID: task.TenantID,
|
||||
TenantKey: task.TenantKey,
|
||||
Provider: candidate.Provider,
|
||||
PlatformID: candidate.PlatformID,
|
||||
PlatformModelID: candidate.PlatformModelID,
|
||||
SourceTaskID: task.ID,
|
||||
SourceAttemptID: attemptID,
|
||||
Model: task.Model,
|
||||
PreviewModel: previewModel,
|
||||
VoiceID: voiceID,
|
||||
DisplayName: firstNonEmptyString(stringFromMap(body, "display_name"), stringFromMap(body, "displayName"), voiceID),
|
||||
DemoAudioURL: demoAudioURL,
|
||||
Status: "active",
|
||||
ExpiresAt: &expiresAt,
|
||||
Metadata: map[string]any{
|
||||
"request": map[string]any{
|
||||
"textValidation": body["text_validation"],
|
||||
"languageBoost": body["language_boost"],
|
||||
"needNoiseReduction": body["need_noise_reduction"],
|
||||
"needVolumeNormalization": body["need_volume_normalization"],
|
||||
"aigcWatermark": body["aigc_watermark"],
|
||||
},
|
||||
"rawData": result["raw_data"],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) touchClonedVoiceUsage(ctx context.Context, user *auth.User, body map[string]any, candidate store.RuntimeModelCandidate) {
|
||||
clonedVoiceID := firstNonEmptyString(stringFromMap(body, "cloned_voice_id"), stringFromMap(body, "clonedVoiceId"))
|
||||
voiceID := firstNonEmptyString(stringFromMap(body, "voice_id"), stringFromMap(body, "voiceId"))
|
||||
voice, found, err := s.store.FindClonedVoiceForUser(ctx, user, clonedVoiceID, voiceID)
|
||||
if err != nil || !found || voice.PlatformID != candidate.PlatformID {
|
||||
return
|
||||
}
|
||||
_ = s.store.TouchClonedVoiceUsage(ctx, voice.ID)
|
||||
}
|
||||
|
||||
func firstAudioURLFromResult(result map[string]any) string {
|
||||
items, _ := result["data"].([]any)
|
||||
for _, raw := range items {
|
||||
item, _ := raw.(map[string]any)
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
if itemType := strings.ToLower(strings.TrimSpace(stringFromAny(item["type"]))); itemType != "" && itemType != "audio" {
|
||||
continue
|
||||
}
|
||||
if url := stringFromAny(item["url"]); url != "" {
|
||||
return url
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func looksLikeUUID(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) != 36 {
|
||||
return false
|
||||
}
|
||||
for index, r := range value {
|
||||
switch index {
|
||||
case 8, 13, 18, 23:
|
||||
if r != '-' {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
Reference in New Issue
Block a user