feat: 支持 MiniMax 音色克隆和 2.8 语音模型

This commit is contained in:
2026-06-17 02:13:21 +08:00
parent 02ba5d3cdd
commit c4341335d7
20 changed files with 1645 additions and 10 deletions
+15
View File
@@ -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
+83 -1
View File
@@ -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
}
+222
View File
@@ -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
}