Skip to content

Commit 0427453

Browse files
committed
feat(asset-gen): add MiniMax music cover support
1 parent c21bf49 commit 0427453

19 files changed

Lines changed: 746 additions & 47 deletions

File tree

MCPForUnity/Editor/Services/AssetGen/AssetGenJobManager.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -443,7 +443,7 @@ private static void Advance(Runner r)
443443
// RCE. Anything outside these sets is rejected. Mirrors ModelImportPipeline's allowlist style.
444444
private static readonly HashSet<string> AudioAllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
445445
{
446-
"wav", "mp3", "ogg", "aiff", "aif", "flac",
446+
"wav", "mp3", "pcm", "ogg", "aiff", "aif", "flac",
447447
};
448448
private static readonly HashSet<string> ImageAllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
449449
{

MCPForUnity/Editor/Services/AssetGen/AssetGenModelCatalog.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,10 @@ public static class AssetGenModelCatalog
6868
new ModelEntry { Id = "cassetteai/music-generator", Label = "CassetteAI Music", Provider = "fal", Kind = "audio", UseCase = "Background music", PriceLabel = "$0.02/min", MaxDurationSeconds = 180f,
6969
DurationField = "duration", DefaultDurationSeconds = 10f, MinDurationSeconds = 1f },
7070
new ModelEntry { Id = "fal-ai/lyria2", Label = "Google Lyria 2", Provider = "fal", Kind = "audio", UseCase = "Background music", PriceLabel = "$0.10/30s", MaxDurationSeconds = 30f },
71+
72+
// Audio cover — MiniMax. Reference input must be 6-360 seconds and no larger than 50 MB.
73+
new ModelEntry { Id = MiniMaxAudioAdapter.DefaultModel, Label = "MiniMax Music Cover", Provider = "minimax", Kind = "audio", UseCase = "Reference-audio cover" },
74+
new ModelEntry { Id = "music-cover-free", Label = "MiniMax Music Cover (free)", Provider = "minimax", Kind = "audio", UseCase = "Reference-audio cover" },
7175
};
7276

7377
/// <summary>Curated entries for a provider+kind, in curated order (default first). Never null.</summary>

MCPForUnity/Editor/Services/AssetGen/Providers/AssetGenProviders.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ public static IAudioProviderAdapter Audio(string id)
4444
{
4545
case "fal":
4646
return new FalAudioAdapter();
47+
case "minimax":
48+
return new MiniMaxAudioAdapter();
4749
default:
4850
throw new NotSupportedException($"Unknown audio provider '{id}'.");
4951
}
@@ -71,6 +73,7 @@ public static IReadOnlyList<ProviderInfo> List()
7173
new ProviderInfo { Id = "openrouter", Kind = "image", Configured = IsConfigured("openrouter"), Capabilities = new[] { "text", "image" } },
7274
// fal appears twice by design — once per kind (image + audio) — sharing the single "fal" key.
7375
new ProviderInfo { Id = "fal", Kind = "audio", Configured = IsConfigured("fal"), Capabilities = new[] { "text", "music", "sfx" } },
76+
new ProviderInfo { Id = "minimax", Kind = "audio", Configured = IsConfigured("minimax"), Capabilities = new[] { "music", "cover", "url", "base64" } },
7477
};
7578
}
7679

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
using System;
2+
using System.Text;
3+
using System.Threading;
4+
using System.Threading.Tasks;
5+
using MCPForUnity.Editor.Security;
6+
using MCPForUnity.Editor.Services.AssetGen.Http;
7+
using Newtonsoft.Json;
8+
using Newtonsoft.Json.Linq;
9+
10+
namespace MCPForUnity.Editor.Services.AssetGen.Providers
11+
{
12+
/// <summary>
13+
/// MiniMax music-cover adapter for the synchronous music-generation endpoint. A cover accepts
14+
/// one raw reference source (URL or base64 audio) and an optional preprocessed feature id, then
15+
/// exposes the returned URL or hex payload through the normal audio job pipeline.
16+
/// </summary>
17+
public sealed class MiniMaxAudioAdapter : IAudioProviderAdapter
18+
{
19+
internal const string GlobalEndpoint = "https://api.minimax.io/v1/music_generation";
20+
internal const string ChinaEndpoint = "https://api.minimaxi.com/v1/music_generation";
21+
internal const string RegionEnvVar = "MCPFORUNITY_MINIMAX_REGION";
22+
internal const string DefaultModel = "music-cover";
23+
24+
private const string GlobalHost = "api.minimax.io";
25+
private const string ChinaHost = "api.minimaxi.com";
26+
private const string FreeModel = "music-cover-free";
27+
28+
private byte[] _inlineData;
29+
private string _downloadUrl;
30+
private string _resultExt;
31+
private string _error;
32+
33+
public string Id => "minimax";
34+
35+
internal static bool IsCoverModel(string model)
36+
=> string.Equals(model, DefaultModel, StringComparison.Ordinal)
37+
|| string.Equals(model, FreeModel, StringComparison.Ordinal);
38+
39+
public async Task<string> SubmitAsync(
40+
AudioGenRequest req,
41+
string apiKey,
42+
IHttpTransport http,
43+
CancellationToken ct)
44+
{
45+
if (req == null) throw new ArgumentNullException(nameof(req));
46+
if (http == null) throw new ArgumentNullException(nameof(http));
47+
48+
string model = string.IsNullOrWhiteSpace(req.Model) ? DefaultModel : req.Model;
49+
ValidateRequest(req, model);
50+
ResolveRegion(out string endpoint, out string host, out bool chinaRegion);
51+
ProviderHttp.RequireHost(endpoint, host, apiKey, "MiniMax cover submit");
52+
53+
var spec = new HttpRequestSpec
54+
{
55+
Method = "POST",
56+
Url = endpoint,
57+
ContentType = "application/json",
58+
Body = Encoding.UTF8.GetBytes(BuildBody(req, model, chinaRegion).ToString(Formatting.None))
59+
};
60+
spec.Headers["Authorization"] = "Bearer " + apiKey;
61+
62+
HttpResult response = await http.SendAsync(spec, ct);
63+
JObject json = ParseOk(response, apiKey);
64+
65+
int statusCode = AsInt(json["base_resp"]?["status_code"], -1);
66+
if (statusCode != 0)
67+
{
68+
string statusMessage = json["base_resp"]?["status_msg"]?.ToString();
69+
_error = SecretRedactor.Scrub(
70+
$"MiniMax music cover failed (status_code={statusCode}): {statusMessage ?? "unknown error"}",
71+
apiKey);
72+
return "ready";
73+
}
74+
75+
JToken data = json["data"];
76+
int generationStatus = AsInt(data?["status"], -1);
77+
if (generationStatus != 2)
78+
{
79+
_error = generationStatus == 1
80+
? "MiniMax music cover is still in progress, but the response included no query endpoint."
81+
: $"MiniMax music cover returned an unexpected status ({generationStatus}).";
82+
return "ready";
83+
}
84+
85+
string audio = data?["audio"]?.ToString();
86+
if (string.IsNullOrWhiteSpace(audio))
87+
{
88+
_error = "MiniMax music cover completed without audio data.";
89+
return "ready";
90+
}
91+
92+
if (Uri.TryCreate(audio, UriKind.Absolute, out Uri audioUri)
93+
&& (audioUri.Scheme == Uri.UriSchemeHttp || audioUri.Scheme == Uri.UriSchemeHttps))
94+
{
95+
_downloadUrl = audio;
96+
}
97+
else
98+
{
99+
_inlineData = TryDecodeHex(audio);
100+
if (_inlineData == null || _inlineData.Length == 0)
101+
{
102+
_error = "MiniMax returned an unrecognized audio payload.";
103+
return "ready";
104+
}
105+
}
106+
107+
_resultExt = NormalizeAudioFormat(req.AudioFormat);
108+
return "ready";
109+
}
110+
111+
public Task<ProviderPollResult> PollAsync(
112+
string providerJobId,
113+
string apiKey,
114+
IHttpTransport http,
115+
CancellationToken ct)
116+
{
117+
var result = new ProviderPollResult { Progress = 1f };
118+
if (!string.IsNullOrEmpty(_error) || (_inlineData == null && string.IsNullOrEmpty(_downloadUrl)))
119+
{
120+
result.State = ProviderPollState.Failed;
121+
result.Error = _error ?? "MiniMax produced no cover audio.";
122+
}
123+
else
124+
{
125+
result.State = ProviderPollState.Succeeded;
126+
result.InlineData = _inlineData;
127+
result.DownloadUrl = _downloadUrl;
128+
result.ResultExt = _resultExt;
129+
}
130+
return Task.FromResult(result);
131+
}
132+
133+
private static void ValidateRequest(AudioGenRequest req, string model)
134+
{
135+
if (!IsCoverModel(model))
136+
throw new NotSupportedException("MiniMax audio supports music-cover and music-cover-free.");
137+
138+
int sourceCount = 0;
139+
if (!string.IsNullOrWhiteSpace(req.AudioUrl)) sourceCount++;
140+
if (!string.IsNullOrWhiteSpace(req.AudioBase64)) sourceCount++;
141+
if (sourceCount != 1)
142+
throw new ArgumentException("Provide exactly one cover source: audio_url or audio_base64.");
143+
144+
NormalizeOutputFormat(req.OutputFormat);
145+
NormalizeAudioFormat(req.AudioFormat);
146+
}
147+
148+
private static JObject BuildBody(AudioGenRequest req, string model, bool chinaRegion)
149+
{
150+
var body = new JObject
151+
{
152+
["model"] = model,
153+
["stream"] = false,
154+
["output_format"] = NormalizeOutputFormat(req.OutputFormat),
155+
["audio_setting"] = new JObject
156+
{
157+
["format"] = NormalizeAudioFormat(req.AudioFormat)
158+
}
159+
};
160+
161+
if (!string.IsNullOrWhiteSpace(req.Prompt)) body["prompt"] = req.Prompt;
162+
if (!string.IsNullOrWhiteSpace(req.Lyrics)) body["lyrics"] = req.Lyrics;
163+
if (req.LyricsOptimizer.HasValue) body["lyrics_optimizer"] = req.LyricsOptimizer.Value;
164+
if (req.IsInstrumental.HasValue) body["is_instrumental"] = req.IsInstrumental.Value;
165+
if (!string.IsNullOrWhiteSpace(req.AudioUrl)) body["audio_url"] = req.AudioUrl;
166+
if (!string.IsNullOrWhiteSpace(req.AudioBase64)) body["audio_base64"] = req.AudioBase64;
167+
if (!string.IsNullOrWhiteSpace(req.CoverFeatureId)) body["cover_feature_id"] = req.CoverFeatureId;
168+
if (chinaRegion && req.AigcWatermark.HasValue) body["aigc_watermark"] = req.AigcWatermark.Value;
169+
return body;
170+
}
171+
172+
private static string NormalizeOutputFormat(string value)
173+
{
174+
string normalized = string.IsNullOrWhiteSpace(value) ? "url" : value.Trim().ToLowerInvariant();
175+
if (normalized != "url" && normalized != "hex")
176+
throw new ArgumentException("output_format must be 'url' or 'hex'.");
177+
return normalized;
178+
}
179+
180+
private static string NormalizeAudioFormat(string value)
181+
{
182+
string normalized = string.IsNullOrWhiteSpace(value) ? "mp3" : value.Trim().ToLowerInvariant();
183+
if (normalized != "mp3" && normalized != "wav" && normalized != "pcm")
184+
throw new ArgumentException("audio_format must be 'mp3', 'wav', or 'pcm'.");
185+
return normalized;
186+
}
187+
188+
private static void ResolveRegion(out string endpoint, out string host, out bool chinaRegion)
189+
{
190+
string region = (Environment.GetEnvironmentVariable(RegionEnvVar) ?? string.Empty)
191+
.Trim().ToLowerInvariant();
192+
chinaRegion = region == "cn" || region == "cn_zh" || region == "china";
193+
endpoint = chinaRegion ? ChinaEndpoint : GlobalEndpoint;
194+
host = chinaRegion ? ChinaHost : GlobalHost;
195+
}
196+
197+
private static int AsInt(JToken token, int fallback)
198+
{
199+
if (token == null || token.Type == JTokenType.Null) return fallback;
200+
if (token.Type == JTokenType.Integer) return (int)token;
201+
return int.TryParse(token.ToString(), out int parsed) ? parsed : fallback;
202+
}
203+
204+
private static byte[] TryDecodeHex(string hex)
205+
{
206+
if (string.IsNullOrEmpty(hex) || (hex.Length % 2) != 0) return null;
207+
var bytes = new byte[hex.Length / 2];
208+
for (int i = 0; i < bytes.Length; i++)
209+
{
210+
int high = HexValue(hex[i * 2]);
211+
int low = HexValue(hex[i * 2 + 1]);
212+
if (high < 0 || low < 0) return null;
213+
bytes[i] = (byte)((high << 4) | low);
214+
}
215+
return bytes;
216+
}
217+
218+
private static int HexValue(char value)
219+
{
220+
if (value >= '0' && value <= '9') return value - '0';
221+
if (value >= 'a' && value <= 'f') return value - 'a' + 10;
222+
if (value >= 'A' && value <= 'F') return value - 'A' + 10;
223+
return -1;
224+
}
225+
226+
private static JObject ParseOk(HttpResult response, string apiKey)
227+
{
228+
string text = ProviderHttp.BodyText(response);
229+
JObject json = null;
230+
if (!string.IsNullOrEmpty(text))
231+
{
232+
try { json = JObject.Parse(text); }
233+
catch { /* handled below */ }
234+
}
235+
236+
if (response?.Ok != true)
237+
{
238+
string detail = json?["base_resp"]?["status_msg"]?.ToString()
239+
?? json?["error"]?.ToString()
240+
?? ProviderHttp.Truncate(text);
241+
throw new Exception(SecretRedactor.Scrub(
242+
$"MiniMax cover request failed (status={response?.Status}): {detail}", apiKey));
243+
}
244+
return json ?? new JObject();
245+
}
246+
}
247+
}

MCPForUnity/Editor/Services/AssetGen/Providers/MiniMaxAudioAdapter.cs.meta

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

MCPForUnity/Editor/Services/AssetGen/Providers/ProviderModels.cs

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,16 +63,25 @@ public sealed class ImageGenRequest
6363
}
6464

6565
/// <summary>
66-
/// Request to generate an audio clip (fal.ai for v1). <see cref="Model"/> selects the fal
67-
/// endpoint (stable-audio-25 / cassetteai/* / lyria2). <see cref="Duration"/> is a per-gen
68-
/// input; 0 => provider default. Never carries a key; never persisted (transient request only).
66+
/// Request to generate an audio clip. Cover-capable providers can consume reference audio as
67+
/// a URL or base64 payload plus optional cover metadata. Never carries a key and is never
68+
/// persisted (transient request only).
6969
/// </summary>
7070
public sealed class AudioGenRequest
7171
{
72-
public string Provider; // "fal" for v1
73-
public string Model; // fal model id, e.g. fal-ai/stable-audio-25/text-to-audio
72+
public string Provider;
73+
public string Model;
7474
public string Prompt;
7575
public float Duration; // seconds; 0 => per-model default. Soft-clamped per model in the adapter.
76+
public string Lyrics;
77+
public bool? LyricsOptimizer;
78+
public bool? IsInstrumental;
79+
public string AudioUrl;
80+
public string AudioBase64;
81+
public string CoverFeatureId;
82+
public string OutputFormat;
83+
public string AudioFormat;
84+
public bool? AigcWatermark;
7685
public string Name;
7786
public string OutputFolder;
7887
}

MCPForUnity/Editor/Tools/AssetGen/GenerateAudio.cs

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88
namespace MCPForUnity.Editor.Tools.AssetGen
99
{
1010
/// <summary>
11-
/// Audio generation (SFX / music) via fal.ai. Triggered here (never from the GUI); the C# side
12-
/// reads the fal key from the secure store and runs the job. Returns a job_id immediately; the
11+
/// Audio generation and cover creation. Triggered here (never from the GUI); the C# side reads
12+
/// the provider key from the secure store and runs the job. Returns a job_id immediately; the
1313
/// client polls the `status` action. When `model` is omitted it falls back to the model selected
1414
/// in the Asset Generation tab, then the catalog default. Status / cancel / list_providers are
1515
/// shared across the generate_* tools via <see cref="AssetGenToolHelpers"/>.
@@ -53,21 +53,43 @@ private static object Generate(ToolParams p)
5353
if (!SecureKeyStore.Current.Has(provider))
5454
return new ErrorResponse(AssetGenProviders.MissingKeyMessage(provider));
5555

56-
string prompt = p.Get("prompt");
57-
if (string.IsNullOrWhiteSpace(prompt))
58-
return new ErrorResponse("'prompt' is required for audio generation.");
59-
6056
// Empty -> GUI-selected model -> catalog default. A null model reaches the adapter's own
6157
// default; a resolved id is passed through verbatim (the catalog default equals the
6258
// adapter constant, so an omitted model is a no-op either way).
6359
string model = AssetGenModelCatalog.ResolveModel("audio", provider, p.Get("model"));
6460

61+
string prompt = p.Get("prompt");
62+
if (string.Equals(provider, "minimax", StringComparison.OrdinalIgnoreCase))
63+
{
64+
if (!MiniMaxAudioAdapter.IsCoverModel(model))
65+
return new ErrorResponse("The MiniMax audio provider supports music-cover and music-cover-free.");
66+
67+
int sourceCount = 0;
68+
if (!string.IsNullOrWhiteSpace(p.Get("audioUrl"))) sourceCount++;
69+
if (!string.IsNullOrWhiteSpace(p.Get("audioBase64"))) sourceCount++;
70+
if (sourceCount != 1)
71+
return new ErrorResponse("Provide exactly one of 'audioUrl' or 'audioBase64'.");
72+
}
73+
else if (string.IsNullOrWhiteSpace(prompt))
74+
{
75+
return new ErrorResponse("'prompt' is required for audio generation.");
76+
}
77+
6578
var req = new AudioGenRequest
6679
{
6780
Provider = provider,
6881
Model = model,
6982
Prompt = prompt,
7083
Duration = p.GetFloat("duration", 0f) ?? 0f,
84+
Lyrics = p.Get("lyrics"),
85+
LyricsOptimizer = p.Has("lyricsOptimizer") ? p.GetBool("lyricsOptimizer") : null,
86+
IsInstrumental = p.Has("isInstrumental") ? p.GetBool("isInstrumental") : null,
87+
AudioUrl = p.Get("audioUrl"),
88+
AudioBase64 = p.Get("audioBase64"),
89+
CoverFeatureId = p.Get("coverFeatureId"),
90+
OutputFormat = p.Get("outputFormat"),
91+
AudioFormat = p.Get("audioFormat"),
92+
AigcWatermark = p.Has("aigcWatermark") ? p.GetBool("aigcWatermark") : null,
7193
Name = p.Get("name"),
7294
OutputFolder = p.Get("outputFolder"),
7395
};

MCPForUnity/Editor/Windows/Components/AssetGen/McpAssetGenSection.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,8 +181,9 @@ private void BuildProviderRows()
181181
AddProviderRow(imagePanel, provider.Id, provider.Label, "image");
182182
}
183183

184-
var audioPanel = AddCategoryPanel("Sound (fal.ai)");
184+
var audioPanel = AddCategoryPanel("Sound");
185185
AddAudioRow(audioPanel);
186+
AddProviderRow(audioPanel, "minimax", "MiniMax", "audio");
186187

187188
AddBlenderHandoffRow();
188189
}

0 commit comments

Comments
 (0)