feat: 支持 MiniMax 音色克隆和 2.8 语音模型
This commit is contained in:
@@ -182,6 +182,88 @@ func TestMinimaxClientSpeechUsesT2AV2AndNormalizesAudio(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinimaxVoiceCloneTextValidationPayload(t *testing.T) {
|
||||
var capturedClone map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer test-key" {
|
||||
t.Fatalf("unexpected auth header: %q", got)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/files/upload":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"file": map[string]any{"file_id": "123456"},
|
||||
"base_resp": map[string]any{"status_code": 0},
|
||||
})
|
||||
case "/voice_clone":
|
||||
if err := json.NewDecoder(r.Body).Decode(&capturedClone); err != nil {
|
||||
t.Fatalf("decode voice clone request: %v", err)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"demo_audio": "",
|
||||
"base_resp": map[string]any{"status_code": 0},
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
_, err := (MinimaxClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
|
||||
Kind: "voice.clone",
|
||||
Model: "MiniMax-Voice-Clone",
|
||||
Body: map[string]any{
|
||||
"voice_id": "voice_test_123",
|
||||
"audio_url": "data:audio/wav;base64," + base64.StdEncoding.EncodeToString([]byte("wave")),
|
||||
"text_validation": false,
|
||||
"need_noise_reduction": true,
|
||||
"need_volume_normalization": true,
|
||||
"aigc_watermark": false,
|
||||
},
|
||||
Candidate: store.RuntimeModelCandidate{
|
||||
Provider: "minimax",
|
||||
BaseURL: server.URL,
|
||||
ProviderModelName: "voice_clone",
|
||||
Credentials: map[string]any{"apiKey": "test-key"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("run minimax voice clone client: %v", err)
|
||||
}
|
||||
if _, ok := capturedClone["text_validation"]; ok {
|
||||
t.Fatalf("legacy boolean text_validation should be omitted: %+v", capturedClone)
|
||||
}
|
||||
if capturedClone["file_id"] != float64(123456) {
|
||||
t.Fatalf("file_id should be submitted as number: %+v", capturedClone)
|
||||
}
|
||||
|
||||
capturedClone = nil
|
||||
_, err = (MinimaxClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
|
||||
Kind: "voice.clone",
|
||||
Model: "MiniMax-Voice-Clone",
|
||||
Body: map[string]any{
|
||||
"voice_id": "voice_test_456",
|
||||
"audio_url": "data:audio/wav;base64," + base64.StdEncoding.EncodeToString([]byte("wave")),
|
||||
"text_validation": " 这是一段用于校验的源音频文本 ",
|
||||
},
|
||||
Candidate: store.RuntimeModelCandidate{
|
||||
Provider: "minimax",
|
||||
BaseURL: server.URL,
|
||||
ProviderModelName: "voice_clone",
|
||||
Credentials: map[string]any{"apiKey": "test-key"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("run minimax voice clone client with transcript: %v", err)
|
||||
}
|
||||
if capturedClone["text_validation"] != "这是一段用于校验的源音频文本" {
|
||||
t.Fatalf("unexpected text_validation payload: %+v", capturedClone)
|
||||
}
|
||||
if capturedClone["file_id"] != float64(123456) {
|
||||
t.Fatalf("file_id should be submitted as number with transcript: %+v", capturedClone)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimulationDurationCanBeControlledByParams(t *testing.T) {
|
||||
fixedDuration := simulationDuration(Request{Body: map[string]any{"simulationDurationSeconds": 7}})
|
||||
if fixedDuration != 7*time.Second {
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -38,6 +45,9 @@ func (c HunyuanVideoClient) Run(ctx context.Context, request Request) (Response,
|
||||
}
|
||||
|
||||
func (c MinimaxClient) Run(ctx context.Context, request Request) (Response, error) {
|
||||
if request.Kind == "voice.clone" {
|
||||
return c.runVoiceClone(ctx, request)
|
||||
}
|
||||
if request.Kind == "speech.generations" {
|
||||
return c.runSpeech(ctx, request)
|
||||
}
|
||||
@@ -337,6 +347,287 @@ func (c MinimaxClient) runSpeech(ctx context.Context, request Request) (Response
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c MinimaxClient) runVoiceClone(ctx context.Context, request Request) (Response, error) {
|
||||
startedAt := time.Now()
|
||||
client := httpClient(request.HTTPClient, c.HTTPClient)
|
||||
body := cloneBody(request.Body)
|
||||
fileID, uploadRequestID, err := c.minimaxVoiceCloneFileID(ctx, client, request, body, "voice_clone", "file_id", "audio", "file", "source_audio", "audio_url")
|
||||
if err != nil {
|
||||
return Response{}, annotateResponseError(err, uploadRequestID, startedAt, time.Now())
|
||||
}
|
||||
payload := minimaxVoiceClonePayload(body, fileID)
|
||||
if clonePrompt := minimaxClonePrompt(body); len(clonePrompt) > 0 {
|
||||
if clonePrompt["prompt_audio"] == nil {
|
||||
promptFileID, promptRequestID, err := c.minimaxVoiceCloneFileID(ctx, client, request, body, "prompt_audio", "prompt_file_id", "prompt_audio", "prompt_audio_url")
|
||||
if err != nil {
|
||||
return Response{}, annotateResponseError(err, firstNonEmptyString(promptRequestID, uploadRequestID), startedAt, time.Now())
|
||||
}
|
||||
if promptFileID != nil {
|
||||
clonePrompt["prompt_audio"] = promptFileID
|
||||
}
|
||||
}
|
||||
if clonePrompt["prompt_audio"] != nil {
|
||||
payload["clone_prompt"] = clonePrompt
|
||||
}
|
||||
}
|
||||
result, requestID, err := providerPostJSON(ctx, client, providerURL(request.Candidate.BaseURL, "/voice_clone"), payload, request.Candidate.Credentials, "bearer")
|
||||
finishedAt := time.Now()
|
||||
if err != nil {
|
||||
return Response{}, annotateResponseError(err, firstNonEmptyString(requestID, uploadRequestID), startedAt, finishedAt)
|
||||
}
|
||||
if isProviderTaskFailure(providerTaskSpec{Name: "minimax"}, result) {
|
||||
return Response{}, providerTaskFailure(providerTaskSpec{Name: "minimax"}, result, firstNonEmptyString(requestID, uploadRequestID, requestIDFromResult(result)), startedAt)
|
||||
}
|
||||
normalized := cloneMapAny(result)
|
||||
normalized["status"] = "success"
|
||||
normalized["created"] = time.Now().UnixMilli()
|
||||
normalized["model"] = request.Model
|
||||
normalized["voice_id"] = stringFromAny(payload["voice_id"])
|
||||
normalized["raw_data"] = cloneMapAny(result)
|
||||
if demoAudio := firstNonEmptyString(valueAtPath(result, "demo_audio"), valueAtPath(result, "data.demo_audio")); demoAudio != "" {
|
||||
normalized["demo_audio"] = demoAudio
|
||||
normalized["data"] = []any{map[string]any{"type": "audio", "url": demoAudio}}
|
||||
}
|
||||
return Response{
|
||||
Result: normalized,
|
||||
RequestID: firstNonEmptyString(requestID, uploadRequestID, requestIDFromResult(result)),
|
||||
Progress: providerProgress(request),
|
||||
ResponseStartedAt: startedAt,
|
||||
ResponseFinishedAt: finishedAt,
|
||||
ResponseDurationMS: responseDurationMS(startedAt, finishedAt),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c MinimaxClient) minimaxVoiceCloneFileID(ctx context.Context, client *http.Client, request Request, body map[string]any, purpose string, fileIDKey string, sourceKeys ...string) (any, string, error) {
|
||||
if value := firstPresent(body[fileIDKey], nil); value != nil {
|
||||
return normalizeMinimaxFileID(value), "", nil
|
||||
}
|
||||
source := firstNonEmptyVoiceCloneSource(body, sourceKeys...)
|
||||
if strings.TrimSpace(source) == "" {
|
||||
if purpose == "prompt_audio" {
|
||||
return nil, "", nil
|
||||
}
|
||||
return nil, "", &ClientError{Code: "bad_request", Message: "file_id or audio is required", Retryable: false}
|
||||
}
|
||||
payload, filename, contentType, err := minimaxVoiceCloneFilePayload(ctx, client, source, purpose)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
result, requestID, err := providerPostMultipartFile(ctx, client, providerURL(request.Candidate.BaseURL, "/files/upload"), request.Candidate.Credentials, "bearer", purpose, filename, contentType, payload)
|
||||
if err != nil {
|
||||
return nil, requestID, err
|
||||
}
|
||||
if isProviderTaskFailure(providerTaskSpec{Name: "minimax"}, result) {
|
||||
return nil, requestID, providerTaskFailure(providerTaskSpec{Name: "minimax"}, result, firstNonEmptyString(requestID, requestIDFromResult(result)), time.Now())
|
||||
}
|
||||
fileID := firstPresent(valueAtPath(result, "file.file_id"), valueAtPath(result, "file_id"))
|
||||
if fileID == nil || strings.TrimSpace(fmt.Sprint(fileID)) == "" || strings.TrimSpace(fmt.Sprint(fileID)) == "<nil>" {
|
||||
return nil, requestID, &ClientError{Code: "invalid_response", Message: "minimax file upload response did not include file_id", RequestID: requestID, Retryable: false}
|
||||
}
|
||||
return normalizeMinimaxFileID(fileID), requestID, nil
|
||||
}
|
||||
|
||||
func minimaxVoiceClonePayload(body map[string]any, fileID any) map[string]any {
|
||||
payload := map[string]any{
|
||||
"file_id": fileID,
|
||||
"voice_id": firstNonEmptyString(body["voice_id"], body["voiceId"]),
|
||||
}
|
||||
for _, key := range []string{"text", "language_boost", "accuracy", "need_noise_reduction", "need_volume_normalization", "aigc_watermark"} {
|
||||
if value, ok := body[key]; ok && value != nil {
|
||||
payload[key] = value
|
||||
}
|
||||
}
|
||||
if textValidation := minimaxVoiceCloneTextValidation(body["text_validation"]); textValidation != "" {
|
||||
payload["text_validation"] = textValidation
|
||||
}
|
||||
if text := strings.TrimSpace(stringFromAny(payload["text"])); text != "" {
|
||||
payload["model"] = firstNonEmptyString(body["preview_model"], body["previewModel"], "speech-2.8-hd")
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func minimaxVoiceCloneTextValidation(value any) string {
|
||||
text := strings.TrimSpace(stringFromAny(value))
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
switch strings.ToLower(text) {
|
||||
case "true", "false", "1", "0", "yes", "no", "on", "off":
|
||||
return ""
|
||||
}
|
||||
if len([]rune(text)) > 200 {
|
||||
return string([]rune(text)[:200])
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func minimaxClonePrompt(body map[string]any) map[string]any {
|
||||
out := map[string]any{}
|
||||
if promptFileID := firstPresent(body["prompt_file_id"], body["promptFileId"]); promptFileID != nil {
|
||||
out["prompt_audio"] = normalizeMinimaxFileID(promptFileID)
|
||||
}
|
||||
if promptText := firstNonEmptyString(body["prompt_text"], body["promptText"]); promptText != "" {
|
||||
out["prompt_text"] = promptText
|
||||
}
|
||||
if len(out) == 1 && out["prompt_text"] != nil {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func firstNonEmptyVoiceCloneSource(body map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
switch value := body[key].(type) {
|
||||
case string:
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
case map[string]any:
|
||||
for _, nestedKey := range []string{"url", "content", "data"} {
|
||||
if text := strings.TrimSpace(stringFromAny(value[nestedKey])); text != "" {
|
||||
return text
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func normalizeMinimaxFileID(value any) any {
|
||||
switch typed := value.(type) {
|
||||
case json.Number:
|
||||
if parsed, err := typed.Int64(); err == nil {
|
||||
return parsed
|
||||
}
|
||||
case float64:
|
||||
return int64(typed)
|
||||
case float32:
|
||||
return int64(typed)
|
||||
case int:
|
||||
return int64(typed)
|
||||
case int64:
|
||||
return typed
|
||||
case int32:
|
||||
return int64(typed)
|
||||
case string:
|
||||
text := strings.TrimSpace(typed)
|
||||
if text != "" {
|
||||
if parsed, err := strconv.ParseInt(text, 10, 64); err == nil {
|
||||
return parsed
|
||||
}
|
||||
return text
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func minimaxVoiceCloneFilePayload(ctx context.Context, client *http.Client, source string, purpose string) ([]byte, string, string, error) {
|
||||
source = strings.TrimSpace(source)
|
||||
if strings.HasPrefix(strings.ToLower(source), "data:") {
|
||||
contentType, payload, err := decodeDataURLPayload(source)
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
return payload, purpose + requestFileExtension(contentType), contentType, nil
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(source), "http://") || strings.HasPrefix(strings.ToLower(source), "https://") {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, source, nil)
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", "", &ClientError{Code: "request_asset_fetch_failed", Message: err.Error(), Retryable: true}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, "", "", &ClientError{Code: "request_asset_fetch_failed", Message: resp.Status, StatusCode: resp.StatusCode, Retryable: HTTPRetryable(resp.StatusCode)}
|
||||
}
|
||||
payload, err := io.ReadAll(io.LimitReader(resp.Body, 24<<20))
|
||||
if err != nil {
|
||||
return nil, "", "", &ClientError{Code: "request_asset_fetch_failed", Message: err.Error(), Retryable: true}
|
||||
}
|
||||
contentType := strings.TrimSpace(resp.Header.Get("Content-Type"))
|
||||
if contentType == "" && len(payload) > 0 {
|
||||
contentType = http.DetectContentType(payload)
|
||||
}
|
||||
return payload, purpose + requestFileExtension(contentType), contentType, nil
|
||||
}
|
||||
return nil, "", "", &ClientError{Code: "bad_request", Message: "audio must be a URL, data URL, or file_id", Retryable: false}
|
||||
}
|
||||
|
||||
func decodeDataURLPayload(value string) (string, []byte, error) {
|
||||
prefix, encoded, ok := strings.Cut(value, ",")
|
||||
if !ok {
|
||||
return "", nil, &ClientError{Code: "bad_request", Message: "invalid data URL audio payload", Retryable: false}
|
||||
}
|
||||
meta := strings.TrimPrefix(strings.TrimPrefix(prefix, "data:"), "DATA:")
|
||||
contentType := strings.TrimSpace(strings.Split(meta, ";")[0])
|
||||
payload, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return "", nil, &ClientError{Code: "bad_request", Message: "invalid base64 audio payload: " + err.Error(), Retryable: false}
|
||||
}
|
||||
if contentType == "" && len(payload) > 0 {
|
||||
contentType = http.DetectContentType(payload)
|
||||
}
|
||||
if contentType == "" {
|
||||
contentType = "audio/mpeg"
|
||||
}
|
||||
return contentType, payload, nil
|
||||
}
|
||||
|
||||
func requestFileExtension(contentType string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0])) {
|
||||
case "audio/mp4", "audio/m4a":
|
||||
return ".m4a"
|
||||
case "audio/wav", "audio/x-wav":
|
||||
return ".wav"
|
||||
default:
|
||||
return ".mp3"
|
||||
}
|
||||
}
|
||||
|
||||
func providerPostMultipartFile(ctx context.Context, client *http.Client, url string, credentials map[string]any, auth string, purpose string, filename string, contentType string, payload []byte) (map[string]any, string, error) {
|
||||
var buf bytes.Buffer
|
||||
writer := multipart.NewWriter(&buf)
|
||||
if err := writer.WriteField("purpose", purpose); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
partHeader := make(textproto.MIMEHeader)
|
||||
partHeader.Set("Content-Disposition", fmt.Sprintf(`form-data; name="file"; filename="%s"`, escapeMultipartFilename(filename)))
|
||||
if strings.TrimSpace(contentType) != "" {
|
||||
partHeader.Set("Content-Type", contentType)
|
||||
}
|
||||
part, err := writer.CreatePart(partHeader)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if _, err := part.Write(payload); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, &buf)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
applyProviderAuth(req, credentials, auth)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", &ClientError{Code: "network", Message: err.Error(), Retryable: true}
|
||||
}
|
||||
requestID := requestIDFromHTTPResponse(resp)
|
||||
result, err := decodeHTTPResponse(resp)
|
||||
return result, requestID, err
|
||||
}
|
||||
|
||||
func escapeMultipartFilename(value string) string {
|
||||
value = strings.ReplaceAll(value, `\`, `\\`)
|
||||
return strings.ReplaceAll(value, `"`, `\"`)
|
||||
}
|
||||
|
||||
func minimaxSpeechPayload(request Request) map[string]any {
|
||||
body := cloneBody(request.Body)
|
||||
body["model"] = upstreamModelName(request.Candidate)
|
||||
|
||||
@@ -176,6 +176,24 @@ func simulatedResult(request Request) map[string]any {
|
||||
"data": simulatedAudioData(request, "simulation speech"),
|
||||
"message": "simulation speech generated",
|
||||
}
|
||||
case "voice.clone":
|
||||
voiceID := strings.TrimSpace(stringValue(request.Body, "voice_id"))
|
||||
if voiceID == "" {
|
||||
voiceID = "SimVoice001"
|
||||
}
|
||||
return map[string]any{
|
||||
"id": "voice-clone-simulated",
|
||||
"created": nowUnix(),
|
||||
"model": request.Model,
|
||||
"status": "success",
|
||||
"voice_id": voiceID,
|
||||
"demo_audio": "/static/simulation/audio.wav",
|
||||
"data": []any{map[string]any{"type": "audio", "url": "/static/simulation/audio.wav", "assetSource": "simulation"}},
|
||||
"message": "simulation voice cloned",
|
||||
"base_resp": map[string]any{"status_code": 0, "status_msg": "success"},
|
||||
"extra_info": map[string]any{"similarity": 1},
|
||||
"input_check": map[string]any{"input_sensitive": false},
|
||||
}
|
||||
default:
|
||||
modelType := strings.ToLower(request.ModelType)
|
||||
kind := strings.ToLower(request.Kind)
|
||||
|
||||
Reference in New Issue
Block a user