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
15 changes: 15 additions & 0 deletions docs/getting-started/HIGH_LEVEL_API.md
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,21 @@ console.log(music.audio[0]?.url);
console.log(sfx.audio[0]?.url);
```

MiniMax Music uses `MINIMAX_API_KEY` and the `minimax-music` provider. It defaults
to Music 3.0 and the global endpoint; select the China endpoint through provider
options:

```ts
const music = await generateMusic({
provider: 'minimax-music',
prompt: 'Cinematic orchestral music with a gradual build.',
providerOptions: { region: 'china', responseFormat: 'url' },
});
```

Request PCM output with `providerOptions.audioSetting.format: 'pcm'`; PCM is a
MiniMax-specific format and is not part of the shared `outputFormat` union.

## Media Provider Preferences

Image, video, music, and SFX helpers accept `providerPreferences` so callers can
Expand Down
11 changes: 6 additions & 5 deletions src/api/generateMusic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
*
* Resolves a music generation provider from explicit `opts.provider`, or by
* probing environment variables in priority order:
* `SUNO_API_KEY` -> `STABILITY_API_KEY` -> `REPLICATE_API_TOKEN` ->
* `FAL_API_KEY` -> (local MusicGen — no key required).
* `SUNO_API_KEY` -> `UDIO_API_KEY` -> `STABILITY_API_KEY` -> `REPLICATE_API_TOKEN` ->
* `FAL_API_KEY` -> `MINIMAX_API_KEY` -> (local MusicGen — no key required).
*
* When multiple music-capable providers are configured (via env vars), the
* primary provider is wrapped in a {@link FallbackAudioProxy} so that a
Expand Down Expand Up @@ -50,6 +50,7 @@ const MUSIC_PROVIDER_ENV_MAP: Array<{ envKey: string | null; providerId: string
{ envKey: 'STABILITY_API_KEY', providerId: 'stable-audio' },
{ envKey: 'REPLICATE_API_TOKEN', providerId: 'replicate-audio' },
{ envKey: 'FAL_API_KEY', providerId: 'fal-audio' },
{ envKey: 'MINIMAX_API_KEY', providerId: 'minimax-music' },
{ envKey: null, providerId: 'musicgen-local' },
];

Expand Down Expand Up @@ -203,8 +204,8 @@ async function createMusicProviderWithFallback(
*
* At minimum, a `prompt` is required. The provider is resolved from
* `opts.provider`, `opts.apiKey`, or the first music-capable env var found
* (`SUNO_API_KEY` -> `STABILITY_API_KEY` -> `REPLICATE_API_TOKEN` ->
* `FAL_API_KEY` -> local MusicGen).
* (`SUNO_API_KEY` -> `UDIO_API_KEY` -> `STABILITY_API_KEY` -> `REPLICATE_API_TOKEN` ->
* `FAL_API_KEY` -> `MINIMAX_API_KEY` -> local MusicGen).
*/
export interface GenerateMusicOptions {
/** Text prompt describing the desired musical composition. */
Expand Down Expand Up @@ -361,7 +362,7 @@ export async function generateMusic(opts: GenerateMusicOptions): Promise<Generat
);
if (providerChain.length === 0) {
throw new Error(
'No music provider configured. Set SUNO_API_KEY, STABILITY_API_KEY, REPLICATE_API_TOKEN, or FAL_API_KEY.',
'No music provider configured. Set SUNO_API_KEY, UDIO_API_KEY, STABILITY_API_KEY, REPLICATE_API_TOKEN, FAL_API_KEY, or MINIMAX_API_KEY.',
);
}
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
providerId = providerChain[0];
Expand Down
14 changes: 14 additions & 0 deletions src/api/runtime/__tests__/generateMusic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ vi.mock('../../../media/audio/index.js', () => {

const defaultModelFor = (providerId: string): string => {
switch (providerId) {
case 'minimax-music':
return 'music-3.0';
case 'stable-audio':
return 'stable-audio-open-1.0';
case 'udio':
Expand Down Expand Up @@ -165,8 +167,20 @@ describe('generateMusic', () => {
expect(result.audio).toHaveLength(1);
});

it('auto-detects provider from MINIMAX_API_KEY env var', async () => {
process.env.MINIMAX_API_KEY = 'env-minimax-key';

const result = await generateMusic({
prompt: 'Cinematic electronic music',
});

expect(result.provider).toBe('minimax-music');
expect(result.model).toBe('music-3.0');
});

it('throws when no provider is configured', async () => {
delete process.env.SUNO_API_KEY;
delete process.env.MINIMAX_API_KEY;
delete process.env.UDIO_API_KEY;
delete process.env.STABILITY_API_KEY;
delete process.env.REPLICATE_API_TOKEN;
Expand Down
210 changes: 210 additions & 0 deletions src/io/media/audio/__tests__/MiniMaxMusicProvider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { MiniMaxMusicProvider } from '../providers/MiniMaxMusicProvider.js';

function mockResponse(body: unknown, ok = true, status = 200, statusText = 'OK') {
return {
ok,
status,
statusText,
json: vi.fn(async () => body),
};
}

describe('MiniMaxMusicProvider', () => {
let provider: MiniMaxMusicProvider;
let fetchSpy: ReturnType<typeof vi.fn>;
const originalFetch = globalThis.fetch;

beforeEach(() => {
provider = new MiniMaxMusicProvider();
fetchSpy = vi.fn();
globalThis.fetch = fetchSpy as unknown as typeof fetch;
});

afterEach(() => {
globalThis.fetch = originalFetch;
});

it('generates Music 3.0 with the global endpoint and URL output', async () => {
await provider.initialize({ apiKey: 'minimax-test-key' });
fetchSpy.mockResolvedValueOnce(mockResponse({
data: { audio: 'https://cdn.example.com/music.mp3', status: 2 },
trace_id: 'trace-global',
extra_info: { music_duration: 12_500, music_sample_rate: 44_100 },
}));

const result = await provider.generateMusic({ prompt: 'Cinematic ambient music' });

expect(result.modelId).toBe('music-3.0');
expect(result.audio[0]).toMatchObject({
url: 'https://cdn.example.com/music.mp3',
mimeType: 'audio/mpeg',
durationSec: 12.5,
sampleRate: 44_100,
});

const [url, options] = fetchSpy.mock.calls[0];
expect(url).toBe('https://api.minimax.io/v1/music_generation');
expect(options.headers.Authorization).toBe('Bearer minimax-test-key');
expect(JSON.parse(options.body)).toMatchObject({
model: 'music-3.0',
prompt: 'Cinematic ambient music',
stream: false,
output_format: 'url',
lyrics_optimizer: true,
is_instrumental: false,
audio_setting: { format: 'mp3' },
});
});

it('uses the China endpoint and converts hex audio to base64', async () => {
await provider.initialize({ apiKey: 'minimax-test-key' });
fetchSpy.mockResolvedValueOnce(mockResponse({
data: { audio: '000102ff', status: 2 },
base_resp: { status_code: 0, status_msg: 'success' },
}));

const result = await provider.generateMusic({
prompt: 'Bright pop song',
outputFormat: 'wav',
providerOptions: {
region: 'china',
responseFormat: 'hex',
lyrics: '[Verse]\nA new day begins',
lyricsOptimizer: false,
aigcWatermark: true,
audioSetting: { sample_rate: 44_100, bitrate: 256_000 },
},
});

expect(result.audio[0]).toMatchObject({
base64: 'AAEC/w==',
mimeType: 'audio/wav',
});

const [url, options] = fetchSpy.mock.calls[0];
expect(url).toBe('https://api.minimaxi.com/v1/music_generation');
expect(JSON.parse(options.body)).toMatchObject({
output_format: 'hex',
lyrics: '[Verse]\nA new day begins',
lyrics_optimizer: false,
aigc_watermark: true,
audio_setting: { sample_rate: 44_100, bitrate: 256_000, format: 'wav' },
});
});

it('passes exactly one reference input for music cover generation', async () => {
await provider.initialize({ apiKey: 'minimax-test-key', defaultModelId: 'music-cover' });
fetchSpy.mockResolvedValueOnce(mockResponse({
data: { audio: 'https://cdn.example.com/cover.mp3', status: 2 },
base_resp: { status_code: 0, status_msg: 'success' },
}));

await provider.generateMusic({
prompt: 'Acoustic folk cover',
providerOptions: { audioUrl: 'https://example.com/reference.wav' },
});

const body = JSON.parse(fetchSpy.mock.calls[0][1].body);
expect(body).toMatchObject({
model: 'music-cover',
audio_url: 'https://example.com/reference.wav',
});
expect(body).not.toHaveProperty('lyrics_optimizer');
expect(body).not.toHaveProperty('is_instrumental');
});

it('requires lyrics when music cover uses a coverFeatureId', async () => {
await provider.initialize({ apiKey: 'minimax-test-key', defaultModelId: 'music-cover' });

await expect(provider.generateMusic({
prompt: 'Acoustic folk cover',
providerOptions: { coverFeatureId: 'feature-123' },
})).rejects.toThrow(
'MiniMax music coverFeatureId requires lyrics between 10 and 1000 characters.',
);
expect(fetchSpy).not.toHaveBeenCalled();
});

it('rejects whitespace-only cover references', async () => {
await provider.initialize({ apiKey: 'minimax-test-key', defaultModelId: 'music-cover' });

await expect(provider.generateMusic({
prompt: 'Acoustic folk cover',
providerOptions: { audioUrl: ' ' },
})).rejects.toThrow(
'MiniMax music cover requires exactly one of audioUrl, audioBase64, or coverFeatureId.',
);
expect(fetchSpy).not.toHaveBeenCalled();
});

it('does not optimize lyrics for instrumental generation', async () => {
await provider.initialize({ apiKey: 'minimax-test-key' });
fetchSpy.mockResolvedValueOnce(mockResponse({
data: { audio: 'https://cdn.example.com/instrumental.mp3', status: 2 },
base_resp: { status_code: 0, status_msg: 'success' },
}));

await provider.generateMusic({
prompt: 'Instrumental post-rock crescendo',
providerOptions: { isInstrumental: true },
});

expect(JSON.parse(fetchSpy.mock.calls[0][1].body)).toMatchObject({
lyrics_optimizer: false,
is_instrumental: true,
});
});

it('surfaces API errors from base_resp', async () => {
await provider.initialize({ apiKey: 'minimax-test-key' });
fetchSpy.mockResolvedValueOnce(mockResponse({
base_resp: { status_code: 1002, status_msg: 'invalid request' },
}));

await expect(provider.generateMusic({ prompt: 'test' }))
.rejects.toThrow('MiniMax music generation failed (200): invalid request');
});

it('preserves HTTP diagnostics for non-JSON error responses', async () => {
await provider.initialize({ apiKey: 'minimax-test-key' });
fetchSpy.mockResolvedValueOnce({
ok: false,
status: 502,
statusText: 'Bad Gateway',
json: vi.fn(async () => { throw new Error('not JSON'); }),
});

await expect(provider.generateMusic({ prompt: 'test' }))
.rejects.toThrow('MiniMax music generation failed (502): Bad Gateway');
});

it('rejects unsupported audio formats before sending a request', async () => {
await provider.initialize({ apiKey: 'minimax-test-key' });

await expect(provider.generateMusic({ prompt: 'test', outputFormat: 'flac' }))
.rejects.toThrow('MiniMax music audio format must be "mp3", "wav", or "pcm".');
expect(fetchSpy).not.toHaveBeenCalled();
});
Comment on lines +183 to +189

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion (testing): Mirror this audio format validation test with one for invalid responseFormat values

There’s equivalent validation for responseFormat (only 'url' and 'hex' are allowed) that isn’t covered. Please add a similar test that calls generateMusic with an invalid responseFormat (e.g. 'foo'), asserts it rejects with "responseFormat must be "url" or "hex"", and confirms fetch is not called.

Suggested change
it('rejects unsupported audio formats before sending a request', async () => {
await provider.initialize({ apiKey: 'minimax-test-key' });
await expect(provider.generateMusic({ prompt: 'test', outputFormat: 'flac' }))
.rejects.toThrow('MiniMax music audio format must be "mp3", "wav", or "pcm".');
expect(fetchSpy).not.toHaveBeenCalled();
});
it('rejects unsupported audio formats before sending a request', async () => {
await provider.initialize({ apiKey: 'minimax-test-key' });
await expect(provider.generateMusic({ prompt: 'test', outputFormat: 'flac' }))
.rejects.toThrow('MiniMax music audio format must be "mp3", "wav", or "pcm".');
expect(fetchSpy).not.toHaveBeenCalled();
});
it('rejects unsupported response formats before sending a request', async () => {
await provider.initialize({ apiKey: 'minimax-test-key' });
await expect(provider.generateMusic({ prompt: 'test', responseFormat: 'foo' }))
.rejects.toThrow('responseFormat must be "url" or "hex"');
expect(fetchSpy).not.toHaveBeenCalled();
});


it('rejects unsupported response formats before sending a request', async () => {
await provider.initialize({ apiKey: 'minimax-test-key' });

await expect(provider.generateMusic({
prompt: 'test',
providerOptions: { responseFormat: 'foo' },
})).rejects.toThrow('MiniMax music responseFormat must be "url" or "hex".');
expect(fetchSpy).not.toHaveBeenCalled();
});

it('rejects unsupported regions before resolving an endpoint', async () => {
await provider.initialize({ apiKey: 'minimax-test-key', baseURL: 'https://proxy.example.com' });

await expect(provider.generateMusic({
prompt: 'test',
providerOptions: { region: 'cn' },
})).rejects.toThrow('MiniMax music region must be "global" or "china".');
expect(fetchSpy).not.toHaveBeenCalled();
});
});
1 change: 1 addition & 0 deletions src/io/media/audio/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ describe('media/audio index', () => {
'audiogen-local',
'elevenlabs-sfx',
'fal-audio',
'minimax-music',
'musicgen-local',
'replicate-audio',
'stable-audio',
Expand Down
3 changes: 3 additions & 0 deletions src/io/media/audio/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { AudioGenLocalProvider } from './providers/AudioGenLocalProvider.js';
import { ElevenLabsSFXProvider } from './providers/ElevenLabsSFXProvider.js';
import { FalAudioProvider } from './providers/FalAudioProvider.js';
import { MusicGenLocalProvider } from './providers/MusicGenLocalProvider.js';
import { MiniMaxMusicProvider } from './providers/MiniMaxMusicProvider.js';
import { ReplicateAudioProvider } from './providers/ReplicateAudioProvider.js';
import { StableAudioProvider } from './providers/StableAudioProvider.js';
import { SunoProvider } from './providers/SunoProvider.js';
Expand All @@ -30,6 +31,7 @@ import { UdioProvider } from './providers/UdioProvider.js';
export * from './types.js';
export * from './IAudioGenerator.js';
export * from './FallbackAudioProxy.js';
export * from './providers/MiniMaxMusicProvider.js';

// ---------------------------------------------------------------------------
// Provider factory registry
Expand All @@ -45,6 +47,7 @@ export type AudioProviderFactory = () => IAudioGenerator;
* `createAudioProvider()` API remains synchronous and ESM-safe.
*/
const audioProviderFactories = new Map<string, AudioProviderFactory>([
['minimax-music', () => new MiniMaxMusicProvider()],
['suno', () => new SunoProvider()],
['udio', () => new UdioProvider()],
['stable-audio', () => new StableAudioProvider()],
Expand Down
Loading