Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion MCPForUnity/Editor/Services/AssetGen/AssetGenJobManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -443,7 +443,7 @@ private static void Advance(Runner r)
// RCE. Anything outside these sets is rejected. Mirrors ModelImportPipeline's allowlist style.
private static readonly HashSet<string> AudioAllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
{
"wav", "mp3", "ogg", "aiff", "aif", "flac",
"wav", "mp3", "pcm", "ogg", "aiff", "aif", "flac",
};
private static readonly HashSet<string> ImageAllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
{
Expand Down
4 changes: 4 additions & 0 deletions MCPForUnity/Editor/Services/AssetGen/AssetGenModelCatalog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ public static class AssetGenModelCatalog
new ModelEntry { Id = "cassetteai/music-generator", Label = "CassetteAI Music", Provider = "fal", Kind = "audio", UseCase = "Background music", PriceLabel = "$0.02/min", MaxDurationSeconds = 180f,
DurationField = "duration", DefaultDurationSeconds = 10f, MinDurationSeconds = 1f },
new ModelEntry { Id = "fal-ai/lyria2", Label = "Google Lyria 2", Provider = "fal", Kind = "audio", UseCase = "Background music", PriceLabel = "$0.10/30s", MaxDurationSeconds = 30f },

// Audio cover — MiniMax. Reference input must be 6-360 seconds and no larger than 50 MB.
new ModelEntry { Id = MiniMaxAudioAdapter.DefaultModel, Label = "MiniMax Music Cover", Provider = "minimax", Kind = "audio", UseCase = "Reference-audio cover" },
new ModelEntry { Id = "music-cover-free", Label = "MiniMax Music Cover (free)", Provider = "minimax", Kind = "audio", UseCase = "Reference-audio cover" },
};

/// <summary>Curated entries for a provider+kind, in curated order (default first). Never null.</summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ public static IAudioProviderAdapter Audio(string id)
{
case "fal":
return new FalAudioAdapter();
case "minimax":
return new MiniMaxAudioAdapter();
default:
throw new NotSupportedException($"Unknown audio provider '{id}'.");
}
Expand Down Expand Up @@ -71,6 +73,7 @@ public static IReadOnlyList<ProviderInfo> List()
new ProviderInfo { Id = "openrouter", Kind = "image", Configured = IsConfigured("openrouter"), Capabilities = new[] { "text", "image" } },
// fal appears twice by design — once per kind (image + audio) — sharing the single "fal" key.
new ProviderInfo { Id = "fal", Kind = "audio", Configured = IsConfigured("fal"), Capabilities = new[] { "text", "music", "sfx" } },
new ProviderInfo { Id = "minimax", Kind = "audio", Configured = IsConfigured("minimax"), Capabilities = new[] { "music", "cover", "url", "base64" } },
};
}

Expand Down
247 changes: 247 additions & 0 deletions MCPForUnity/Editor/Services/AssetGen/Providers/MiniMaxAudioAdapter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
using System;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using MCPForUnity.Editor.Security;
using MCPForUnity.Editor.Services.AssetGen.Http;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;

namespace MCPForUnity.Editor.Services.AssetGen.Providers
{
/// <summary>
/// MiniMax music-cover adapter for the synchronous music-generation endpoint. A cover accepts
/// one raw reference source (URL or base64 audio) and an optional preprocessed feature id, then
/// exposes the returned URL or hex payload through the normal audio job pipeline.
/// </summary>
public sealed class MiniMaxAudioAdapter : IAudioProviderAdapter
{
internal const string GlobalEndpoint = "https://api.minimax.io/v1/music_generation";
internal const string ChinaEndpoint = "https://api.minimaxi.com/v1/music_generation";
internal const string RegionEnvVar = "MCPFORUNITY_MINIMAX_REGION";
internal const string DefaultModel = "music-cover";

private const string GlobalHost = "api.minimax.io";
private const string ChinaHost = "api.minimaxi.com";
private const string FreeModel = "music-cover-free";

private byte[] _inlineData;
private string _downloadUrl;
private string _resultExt;
private string _error;

public string Id => "minimax";

internal static bool IsCoverModel(string model)
=> string.Equals(model, DefaultModel, StringComparison.Ordinal)
|| string.Equals(model, FreeModel, StringComparison.Ordinal);

public async Task<string> SubmitAsync(
AudioGenRequest req,
string apiKey,
IHttpTransport http,
CancellationToken ct)
{
if (req == null) throw new ArgumentNullException(nameof(req));
if (http == null) throw new ArgumentNullException(nameof(http));

string model = string.IsNullOrWhiteSpace(req.Model) ? DefaultModel : req.Model;
ValidateRequest(req, model);
ResolveRegion(out string endpoint, out string host, out bool chinaRegion);
ProviderHttp.RequireHost(endpoint, host, apiKey, "MiniMax cover submit");

var spec = new HttpRequestSpec
{
Method = "POST",
Url = endpoint,
ContentType = "application/json",
Body = Encoding.UTF8.GetBytes(BuildBody(req, model, chinaRegion).ToString(Formatting.None))
};
spec.Headers["Authorization"] = "Bearer " + apiKey;

HttpResult response = await http.SendAsync(spec, ct);
JObject json = ParseOk(response, apiKey);

int statusCode = AsInt(json["base_resp"]?["status_code"], -1);
if (statusCode != 0)
{
string statusMessage = json["base_resp"]?["status_msg"]?.ToString();
_error = SecretRedactor.Scrub(
$"MiniMax music cover failed (status_code={statusCode}): {statusMessage ?? "unknown error"}",
apiKey);
return "ready";
}

JToken data = json["data"];
int generationStatus = AsInt(data?["status"], -1);
if (generationStatus != 2)
{
_error = generationStatus == 1
? "MiniMax music cover is still in progress, but the response included no query endpoint."
: $"MiniMax music cover returned an unexpected status ({generationStatus}).";
return "ready";
}

string audio = data?["audio"]?.ToString();
if (string.IsNullOrWhiteSpace(audio))
{
_error = "MiniMax music cover completed without audio data.";
return "ready";
}

if (Uri.TryCreate(audio, UriKind.Absolute, out Uri audioUri)
&& (audioUri.Scheme == Uri.UriSchemeHttp || audioUri.Scheme == Uri.UriSchemeHttps))
{
_downloadUrl = audio;
Comment on lines +92 to +95

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Restrict provider-returned download URLs before use.

audio can contain any HTTP(S) URL. AssetGenJobManager.IsAllowedDownloadUrl checks only the scheme. It permits loopback, link-local, and private-network hosts.

A compromised or malicious provider response can make the Unity Editor request internal services. Enforce private-address and redirect validation in the shared transport or download policy before the request starts.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@MCPForUnity/Editor/Services/AssetGen/Providers/MiniMaxAudioAdapter.cs` around
lines 92 - 95, Update the shared download policy or transport used by
AssetGenJobManager.IsAllowedDownloadUrl to validate resolved hosts and reject
loopback, link-local, private-network, and other non-public addresses, while
also preventing redirects to disallowed destinations. Ensure the
MiniMaxAudioAdapter assignment to _downloadUrl cannot initiate a request until
the complete URL and redirect chain pass these checks.

}
else
{
_inlineData = TryDecodeHex(audio);
if (_inlineData == null || _inlineData.Length == 0)
{
_error = "MiniMax returned an unrecognized audio payload.";
return "ready";
}
}

_resultExt = NormalizeAudioFormat(req.AudioFormat);
return "ready";
}

public Task<ProviderPollResult> PollAsync(
string providerJobId,
string apiKey,
IHttpTransport http,
CancellationToken ct)
{
var result = new ProviderPollResult { Progress = 1f };
if (!string.IsNullOrEmpty(_error) || (_inlineData == null && string.IsNullOrEmpty(_downloadUrl)))
{
result.State = ProviderPollState.Failed;
result.Error = _error ?? "MiniMax produced no cover audio.";
}
else
{
result.State = ProviderPollState.Succeeded;
result.InlineData = _inlineData;
result.DownloadUrl = _downloadUrl;
result.ResultExt = _resultExt;
}
return Task.FromResult(result);
}

private static void ValidateRequest(AudioGenRequest req, string model)
{
if (!IsCoverModel(model))
throw new NotSupportedException("MiniMax audio supports music-cover and music-cover-free.");

int sourceCount = 0;
if (!string.IsNullOrWhiteSpace(req.AudioUrl)) sourceCount++;
if (!string.IsNullOrWhiteSpace(req.AudioBase64)) sourceCount++;
if (sourceCount != 1)
throw new ArgumentException("Provide exactly one cover source: audio_url or audio_base64.");

NormalizeOutputFormat(req.OutputFormat);
NormalizeAudioFormat(req.AudioFormat);
}

private static JObject BuildBody(AudioGenRequest req, string model, bool chinaRegion)
{
var body = new JObject
{
["model"] = model,
["stream"] = false,
["output_format"] = NormalizeOutputFormat(req.OutputFormat),
["audio_setting"] = new JObject
{
["format"] = NormalizeAudioFormat(req.AudioFormat)
}
};

if (!string.IsNullOrWhiteSpace(req.Prompt)) body["prompt"] = req.Prompt;
if (!string.IsNullOrWhiteSpace(req.Lyrics)) body["lyrics"] = req.Lyrics;
if (req.LyricsOptimizer.HasValue) body["lyrics_optimizer"] = req.LyricsOptimizer.Value;
if (req.IsInstrumental.HasValue) body["is_instrumental"] = req.IsInstrumental.Value;
if (!string.IsNullOrWhiteSpace(req.AudioUrl)) body["audio_url"] = req.AudioUrl;
if (!string.IsNullOrWhiteSpace(req.AudioBase64)) body["audio_base64"] = req.AudioBase64;
if (!string.IsNullOrWhiteSpace(req.CoverFeatureId)) body["cover_feature_id"] = req.CoverFeatureId;
if (chinaRegion && req.AigcWatermark.HasValue) body["aigc_watermark"] = req.AigcWatermark.Value;
return body;
}

private static string NormalizeOutputFormat(string value)
{
string normalized = string.IsNullOrWhiteSpace(value) ? "url" : value.Trim().ToLowerInvariant();
if (normalized != "url" && normalized != "hex")
throw new ArgumentException("output_format must be 'url' or 'hex'.");
return normalized;
}

private static string NormalizeAudioFormat(string value)
{
string normalized = string.IsNullOrWhiteSpace(value) ? "mp3" : value.Trim().ToLowerInvariant();
if (normalized != "mp3" && normalized != "wav" && normalized != "pcm")
throw new ArgumentException("audio_format must be 'mp3', 'wav', or 'pcm'.");
return normalized;
Comment on lines +180 to +185

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

fd -a 'ProjectVersion.txt' .
fd -a -a 'AudioImportPipeline.cs' MCPForUnity TestProjects
rg -n -C 5 --glob '*.cs' 'pcm|AudioImportPipeline|AudioClip|AssetImporter' MCPForUnity TestProjects

Repository: CoplayDev/unity-mcp

Length of output: 50375


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- MiniMaxAudioAdapter ---'
sed -n '1,240p' MCPForUnity/Editor/Services/AssetGen/Providers/MiniMaxAudioAdapter.cs

printf '%s\n' '--- AssetGenJobManager write/import flow ---'
sed -n '120,190p' MCPForUnity/Editor/Services/AssetGen/AssetGenJobManager.cs
sed -n '390,520p' MCPForUnity/Editor/Services/AssetGen/AssetGenJobManager.cs

printf '%s\n' '--- AudioImportPipeline ---'
cat -n MCPForUnity/Editor/Services/AssetGen/Import/AudioImportPipeline.cs

printf '%s\n' '--- Audio-related request/result models and tests ---'
rg -n -C 4 --glob '*.cs' 'class .*Audio|AudioFormat|OutputFormat|ResultExt|InlineData|OverrideExt|IsAllowedResultExtension|ImportOverrideForTests' MCPForUnity/Editor/Services/AssetGen TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen

Repository: CoplayDev/unity-mcp

Length of output: 50375


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- Runner poll/download/import state transitions ---'
sed -n '300,438p' MCPForUnity/Editor/Services/AssetGen/AssetGenJobManager.cs

printf '%s\n' '--- MiniMax adapter tests ---'
cat -n TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/MiniMaxAudioAdapterTests.cs

printf '%s\n' '--- Audio pipeline tests ---'
cat -n TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AudioImportPipelineTests.cs

printf '%s\n' '--- Extension allowlist tests and audio test helpers ---'
rg -n -C 8 --glob '*.cs' 'IsAllowedResultExtension|StartAudioGeneration|ImportOverrideForTests|OutputFormat|AudioFormat|MiniMax' TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen MCPForUnity/Editor/Services/AssetGen

Repository: CoplayDev/unity-mcp

Length of output: 50376


🌐 Web query:

Unity Manual audio file compatibility raw PCM .pcm supported import formats AudioImporter

💡 Result:

Unity does not support the import of raw PCM (.pcm) files directly [1][2][3]. While "PCM" is a widely supported compression format within Unity for runtime audio storage and playback, this refers to an uncompressed encoding method that Unity can apply to audio files during the import and transcoding process [4][5]. It is not a supported source file format for the AudioImporter [3][6]. The official supported source audio file formats that can be imported into Unity are: - Microsoft Wave (.wav) [1][2][3] - Audio Interchange File Format (.aiff,.aif) [1][2][3] - MPEG layer 3 (.mp3) [1][2][3] - Ogg Vorbis (.ogg) [1][2][3] - Free Lossless Audio Codec (.flac) [1][2][3] - Tracker modules (.mod,.it,.s3m,.xm) [7][2][3] When you import any of these supported files, the Unity AudioImporter allows you to specify the runtime compression format, where "PCM" can be selected to ensure the audio remains uncompressed for optimal CPU performance at the cost of larger file size [4][5]. Raw PCM data lacking a container format (like a.wav header) cannot be natively processed by the AudioImporter [3][6].

Citations:


Do not expose raw pcm as an importable audio result.

The job writes pcm bytes as .pcm under Assets/, but Unity does not import raw PCM source files. The bytes lack container metadata such as sample rate and channel count. The job can therefore fail or produce no usable AudioClip.

  • Reject pcm unless the pipeline converts it to a supported container.
  • Remove pcm from AudioAllowedExtensions unless a custom PCM importer exists.
  • Add end-to-end import coverage for each accepted audio format.
📍 Affects 3 files
  • MCPForUnity/Editor/Services/AssetGen/Providers/MiniMaxAudioAdapter.cs#L180-L185 (this comment)
  • MCPForUnity/Editor/Services/AssetGen/AssetGenJobManager.cs#L446-L446
  • TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/MiniMaxAudioAdapterTests.cs#L127-L146
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@MCPForUnity/Editor/Services/AssetGen/Providers/MiniMaxAudioAdapter.cs` around
lines 180 - 185, The audio pipeline must not expose raw pcm files as importable
results. In
MCPForUnity/Editor/Services/AssetGen/Providers/MiniMaxAudioAdapter.cs:180-185,
update NormalizeAudioFormat to reject pcm unless conversion to a supported
container is implemented; in
MCPForUnity/Editor/Services/AssetGen/AssetGenJobManager.cs:446, remove pcm from
AudioAllowedExtensions unless a custom importer exists; and in
TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/MiniMaxAudioAdapterTests.cs:127-146,
add end-to-end import coverage for every remaining accepted audio format.

}

private static void ResolveRegion(out string endpoint, out string host, out bool chinaRegion)
{
string region = (Environment.GetEnvironmentVariable(RegionEnvVar) ?? string.Empty)
.Trim().ToLowerInvariant();
chinaRegion = region == "cn" || region == "cn_zh" || region == "china";
endpoint = chinaRegion ? ChinaEndpoint : GlobalEndpoint;
host = chinaRegion ? ChinaHost : GlobalHost;
}

private static int AsInt(JToken token, int fallback)
{
if (token == null || token.Type == JTokenType.Null) return fallback;
if (token.Type == JTokenType.Integer) return (int)token;
return int.TryParse(token.ToString(), out int parsed) ? parsed : fallback;
}

private static byte[] TryDecodeHex(string hex)
{
if (string.IsNullOrEmpty(hex) || (hex.Length % 2) != 0) return null;
var bytes = new byte[hex.Length / 2];
for (int i = 0; i < bytes.Length; i++)
{
int high = HexValue(hex[i * 2]);
int low = HexValue(hex[i * 2 + 1]);
if (high < 0 || low < 0) return null;
bytes[i] = (byte)((high << 4) | low);
}
return bytes;
}

private static int HexValue(char value)
{
if (value >= '0' && value <= '9') return value - '0';
if (value >= 'a' && value <= 'f') return value - 'a' + 10;
if (value >= 'A' && value <= 'F') return value - 'A' + 10;
return -1;
}

private static JObject ParseOk(HttpResult response, string apiKey)
{
string text = ProviderHttp.BodyText(response);
JObject json = null;
if (!string.IsNullOrEmpty(text))
{
try { json = JObject.Parse(text); }
catch { /* handled below */ }
}

if (response?.Ok != true)
{
string detail = json?["base_resp"]?["status_msg"]?.ToString()
?? json?["error"]?.ToString()
?? ProviderHttp.Truncate(text);
throw new Exception(SecretRedactor.Scrub(
$"MiniMax cover request failed (status={response?.Status}): {detail}", apiKey));
}
return json ?? new JObject();
}
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 14 additions & 5 deletions MCPForUnity/Editor/Services/AssetGen/Providers/ProviderModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,16 +63,25 @@ public sealed class ImageGenRequest
}

/// <summary>
/// Request to generate an audio clip (fal.ai for v1). <see cref="Model"/> selects the fal
/// endpoint (stable-audio-25 / cassetteai/* / lyria2). <see cref="Duration"/> is a per-gen
/// input; 0 => provider default. Never carries a key; never persisted (transient request only).
/// Request to generate an audio clip. Cover-capable providers can consume reference audio as
/// a URL or base64 payload plus optional cover metadata. Never carries a key and is never
/// persisted (transient request only).
/// </summary>
public sealed class AudioGenRequest
{
public string Provider; // "fal" for v1
public string Model; // fal model id, e.g. fal-ai/stable-audio-25/text-to-audio
public string Provider;
public string Model;
public string Prompt;
public float Duration; // seconds; 0 => per-model default. Soft-clamped per model in the adapter.
public string Lyrics;
public bool? LyricsOptimizer;
public bool? IsInstrumental;
public string AudioUrl;
public string AudioBase64;
public string CoverFeatureId;
public string OutputFormat;
public string AudioFormat;
public bool? AigcWatermark;
public string Name;
public string OutputFolder;
}
Expand Down
34 changes: 28 additions & 6 deletions MCPForUnity/Editor/Tools/AssetGen/GenerateAudio.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
namespace MCPForUnity.Editor.Tools.AssetGen
{
/// <summary>
/// Audio generation (SFX / music) via fal.ai. Triggered here (never from the GUI); the C# side
/// reads the fal key from the secure store and runs the job. Returns a job_id immediately; the
/// Audio generation and cover creation. Triggered here (never from the GUI); the C# side reads
/// the provider key from the secure store and runs the job. Returns a job_id immediately; the
/// client polls the `status` action. When `model` is omitted it falls back to the model selected
/// in the Asset Generation tab, then the catalog default. Status / cancel / list_providers are
/// shared across the generate_* tools via <see cref="AssetGenToolHelpers"/>.
Expand Down Expand Up @@ -53,21 +53,43 @@ private static object Generate(ToolParams p)
if (!SecureKeyStore.Current.Has(provider))
return new ErrorResponse(AssetGenProviders.MissingKeyMessage(provider));

string prompt = p.Get("prompt");
if (string.IsNullOrWhiteSpace(prompt))
return new ErrorResponse("'prompt' is required for audio generation.");

// Empty -> GUI-selected model -> catalog default. A null model reaches the adapter's own
// default; a resolved id is passed through verbatim (the catalog default equals the
// adapter constant, so an omitted model is a no-op either way).
string model = AssetGenModelCatalog.ResolveModel("audio", provider, p.Get("model"));

string prompt = p.Get("prompt");
if (string.Equals(provider, "minimax", StringComparison.OrdinalIgnoreCase))
{
if (!MiniMaxAudioAdapter.IsCoverModel(model))
return new ErrorResponse("The MiniMax audio provider supports music-cover and music-cover-free.");

int sourceCount = 0;
if (!string.IsNullOrWhiteSpace(p.Get("audioUrl"))) sourceCount++;
if (!string.IsNullOrWhiteSpace(p.Get("audioBase64"))) sourceCount++;
if (sourceCount != 1)
return new ErrorResponse("Provide exactly one of 'audioUrl' or 'audioBase64'.");
}
else if (string.IsNullOrWhiteSpace(prompt))
{
return new ErrorResponse("'prompt' is required for audio generation.");
}

var req = new AudioGenRequest
{
Provider = provider,
Model = model,
Prompt = prompt,
Duration = p.GetFloat("duration", 0f) ?? 0f,
Lyrics = p.Get("lyrics"),
LyricsOptimizer = p.Has("lyricsOptimizer") ? p.GetBool("lyricsOptimizer") : null,
IsInstrumental = p.Has("isInstrumental") ? p.GetBool("isInstrumental") : null,
AudioUrl = p.Get("audioUrl"),
AudioBase64 = p.Get("audioBase64"),
CoverFeatureId = p.Get("coverFeatureId"),
OutputFormat = p.Get("outputFormat"),
AudioFormat = p.Get("audioFormat"),
AigcWatermark = p.Has("aigcWatermark") ? p.GetBool("aigcWatermark") : null,
Name = p.Get("name"),
OutputFolder = p.Get("outputFolder"),
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -181,8 +181,9 @@ private void BuildProviderRows()
AddProviderRow(imagePanel, provider.Id, provider.Label, "image");
}

var audioPanel = AddCategoryPanel("Sound (fal.ai)");
var audioPanel = AddCategoryPanel("Sound");
AddAudioRow(audioPanel);
AddProviderRow(audioPanel, "minimax", "MiniMax", "audio");

AddBlenderHandoffRow();
}
Expand Down
Loading