diff --git a/docs/getting-started/HIGH_LEVEL_API.md b/docs/getting-started/HIGH_LEVEL_API.md index 04f904f03c8..b238096fbdb 100644 --- a/docs/getting-started/HIGH_LEVEL_API.md +++ b/docs/getting-started/HIGH_LEVEL_API.md @@ -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 diff --git a/src/api/generateMusic.ts b/src/api/generateMusic.ts index a6a72996f84..d6b164eecf4 100644 --- a/src/api/generateMusic.ts +++ b/src/api/generateMusic.ts @@ -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 @@ -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' }, ]; @@ -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. */ @@ -361,7 +362,7 @@ export async function generateMusic(opts: GenerateMusicOptions): Promise { 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': @@ -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; diff --git a/src/io/media/audio/__tests__/MiniMaxMusicProvider.test.ts b/src/io/media/audio/__tests__/MiniMaxMusicProvider.test.ts new file mode 100644 index 00000000000..e621c61d971 --- /dev/null +++ b/src/io/media/audio/__tests__/MiniMaxMusicProvider.test.ts @@ -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; + 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(); + }); + + 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(); + }); +}); diff --git a/src/io/media/audio/__tests__/index.test.ts b/src/io/media/audio/__tests__/index.test.ts index 51814afc71c..6fe1a2426f1 100644 --- a/src/io/media/audio/__tests__/index.test.ts +++ b/src/io/media/audio/__tests__/index.test.ts @@ -8,6 +8,7 @@ describe('media/audio index', () => { 'audiogen-local', 'elevenlabs-sfx', 'fal-audio', + 'minimax-music', 'musicgen-local', 'replicate-audio', 'stable-audio', diff --git a/src/io/media/audio/index.ts b/src/io/media/audio/index.ts index 7f38cbb0fdd..e8264009793 100644 --- a/src/io/media/audio/index.ts +++ b/src/io/media/audio/index.ts @@ -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'; @@ -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 @@ -45,6 +47,7 @@ export type AudioProviderFactory = () => IAudioGenerator; * `createAudioProvider()` API remains synchronous and ESM-safe. */ const audioProviderFactories = new Map([ + ['minimax-music', () => new MiniMaxMusicProvider()], ['suno', () => new SunoProvider()], ['udio', () => new UdioProvider()], ['stable-audio', () => new StableAudioProvider()], diff --git a/src/io/media/audio/providers/MiniMaxMusicProvider.ts b/src/io/media/audio/providers/MiniMaxMusicProvider.ts new file mode 100644 index 00000000000..39180b7fe02 --- /dev/null +++ b/src/io/media/audio/providers/MiniMaxMusicProvider.ts @@ -0,0 +1,271 @@ +/** + * Music generation provider for the MiniMax Music API. + */ + +import { ApiKeyPool } from '../../../../core/providers/ApiKeyPool.js'; +import type { IAudioGenerator } from '../IAudioGenerator.js'; +import type { + AudioResult, + MusicGenerateRequest, +} from '../types.js'; + +export type MiniMaxMusicRegion = 'global' | 'china'; +export type MiniMaxMusicResponseFormat = 'url' | 'hex'; +export type MiniMaxMusicAudioFormat = 'mp3' | 'wav' | 'pcm'; + +export interface MiniMaxMusicProviderConfig { + apiKey: string; + baseURL?: string; + region?: MiniMaxMusicRegion; + defaultModelId?: string; +} + +export interface MiniMaxMusicProviderOptions { + region?: MiniMaxMusicRegion; + baseURL?: string; + responseFormat?: MiniMaxMusicResponseFormat; + lyrics?: string; + lyricsOptimizer?: boolean; + isInstrumental?: boolean; + audioSetting?: { + sample_rate?: number; + bitrate?: number; + format?: MiniMaxMusicAudioFormat; + }; + aigcWatermark?: boolean; + audioUrl?: string; + audioBase64?: string; + coverFeatureId?: string; +} + +interface MiniMaxMusicResponse { + data?: { + audio?: string; + status?: number; + }; + trace_id?: string; + extra_info?: { + music_duration?: number; + music_sample_rate?: number; + music_channel?: number; + bitrate?: number; + music_size?: number; + }; + base_resp?: { + status_code?: number; + status_msg?: string; + }; +} + +const MUSIC_ENDPOINTS: Record = { + global: 'https://api.minimax.io/v1/music_generation', + china: 'https://api.minimaxi.com/v1/music_generation', +}; + +const AUDIO_MIME_TYPES: Record = { + mp3: 'audio/mpeg', + wav: 'audio/wav', + pcm: 'audio/pcm', +}; + +function asOptions(value: Record | undefined): MiniMaxMusicProviderOptions { + return (value ?? {}) as MiniMaxMusicProviderOptions; +} + +function hexToBase64(hex: string): string { + if (hex.length % 2 !== 0 || !/^[0-9a-f]*$/i.test(hex)) { + throw new Error('MiniMax music generation returned invalid hex audio.'); + } + + const bytes = new Uint8Array(hex.length / 2); + for (let index = 0; index < bytes.length; index += 1) { + bytes[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16); + } + + let binary = ''; + for (let offset = 0; offset < bytes.length; offset += 0x8000) { + binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000)); + } + return globalThis.btoa(binary); +} + +export class MiniMaxMusicProvider implements IAudioGenerator { + public readonly providerId = 'minimax-music'; + public isInitialized = false; + public defaultModelId?: string; + + private _config!: Required< + Pick + >; + private keyPool!: ApiKeyPool; + + async initialize(config: Record): Promise { + const apiKey = typeof config.apiKey === 'string' ? config.apiKey.trim() : ''; + if (!apiKey) { + throw new Error('MiniMax Music provider requires apiKey (MINIMAX_API_KEY).'); + } + + const region: MiniMaxMusicRegion = config.region === 'china' ? 'china' : 'global'; + this._config = { + apiKey, + region, + baseURL: + typeof config.baseURL === 'string' && config.baseURL.trim() + ? config.baseURL.trim() + : MUSIC_ENDPOINTS[region], + defaultModelId: + typeof config.defaultModelId === 'string' && config.defaultModelId.trim() + ? config.defaultModelId.trim() + : 'music-3.0', + }; + + this.defaultModelId = this._config.defaultModelId; + this.keyPool = new ApiKeyPool(apiKey); + this.isInitialized = true; + } + + async generateMusic(request: MusicGenerateRequest): Promise { + if (!this.isInitialized) { + throw new Error('MiniMax Music provider is not initialized. Call initialize() first.'); + } + + const options = asOptions(request.providerOptions); + const model = request.modelId || this.defaultModelId || 'music-3.0'; + if (options.region !== undefined && options.region !== 'china' && options.region !== 'global') { + throw new Error('MiniMax music region must be "global" or "china".'); + } + const regionOverride = options.region === 'china' || options.region === 'global' + ? options.region + : undefined; + const region = regionOverride ?? this._config.region; + const endpoint = options.baseURL?.trim() || + (regionOverride ? MUSIC_ENDPOINTS[region] : this._config.baseURL); + const responseFormat = options.responseFormat ?? 'url'; + if (responseFormat !== 'url' && responseFormat !== 'hex') { + throw new Error('MiniMax music responseFormat must be "url" or "hex".'); + } + const requestedAudioFormat = options.audioSetting?.format ?? request.outputFormat ?? 'mp3'; + if (requestedAudioFormat !== 'mp3' && requestedAudioFormat !== 'wav' && requestedAudioFormat !== 'pcm') { + throw new Error('MiniMax music audio format must be "mp3", "wav", or "pcm".'); + } + const audioFormat: MiniMaxMusicAudioFormat = requestedAudioFormat; + const isCover = model === 'music-cover' || model === 'music-cover-free'; + const audioUrl = typeof options.audioUrl === 'string' ? options.audioUrl.trim() : ''; + const audioBase64 = typeof options.audioBase64 === 'string' ? options.audioBase64.trim() : ''; + const coverFeatureId = typeof options.coverFeatureId === 'string' + ? options.coverFeatureId.trim() + : ''; + const referenceInputs = [audioUrl, audioBase64, coverFeatureId].filter(Boolean); + + if (isCover && referenceInputs.length !== 1) { + throw new Error( + 'MiniMax music cover requires exactly one of audioUrl, audioBase64, or coverFeatureId.', + ); + } + const lyricsLength = typeof options.lyrics === 'string' ? options.lyrics.trim().length : 0; + if (isCover && coverFeatureId && (lyricsLength < 10 || lyricsLength > 1000)) { + throw new Error('MiniMax music coverFeatureId requires lyrics between 10 and 1000 characters.'); + } + + const body: Record = { + model, + prompt: request.prompt, + stream: false, + output_format: responseFormat, + audio_setting: { + ...options.audioSetting, + format: audioFormat, + }, + }; + + if (options.lyrics !== undefined) body.lyrics = options.lyrics; + if (!isCover) { + body.lyrics_optimizer = + options.lyricsOptimizer ?? (!options.lyrics && !options.isInstrumental); + body.is_instrumental = options.isInstrumental ?? false; + } + if (region === 'china' && options.aigcWatermark !== undefined) { + body.aigc_watermark = options.aigcWatermark; + } + if (audioUrl) body.audio_url = audioUrl; + if (audioBase64) body.audio_base64 = audioBase64; + if (coverFeatureId) body.cover_feature_id = coverFeatureId; + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + Authorization: `Bearer ${this.keyPool.next()}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + let message = response.statusText || 'request failed'; + try { + const errorPayload = await response.json() as MiniMaxMusicResponse; + message = errorPayload.base_resp?.status_msg || message; + } catch { + // Preserve the HTTP status when the error body is empty or non-JSON. + } + throw new Error(`MiniMax music generation failed (${response.status}): ${message}`); + } + + let payload: MiniMaxMusicResponse; + try { + payload = await response.json() as MiniMaxMusicResponse; + } catch { + throw new Error(`MiniMax music generation returned invalid JSON (${response.status}).`); + } + const apiStatusCode = payload.base_resp?.status_code; + if (apiStatusCode !== undefined && apiStatusCode !== 0) { + const message = payload.base_resp?.status_msg || response.statusText || 'unknown error'; + throw new Error(`MiniMax music generation failed (${response.status}): ${message}`); + } + if (payload.data?.status !== 2) { + throw new Error(`MiniMax music generation returned incomplete status ${payload.data?.status}.`); + } + + const audio = payload.data.audio; + if (!audio) { + throw new Error('MiniMax music generation completed without audio output.'); + } + + const durationMs = payload.extra_info?.music_duration; + const generatedAudio = { + ...(responseFormat === 'url' + ? { url: audio } + : { base64: hexToBase64(audio) }), + mimeType: AUDIO_MIME_TYPES[audioFormat], + ...(durationMs !== undefined ? { durationSec: durationMs / 1000 } : {}), + ...(payload.extra_info?.music_sample_rate !== undefined + ? { sampleRate: payload.extra_info.music_sample_rate } + : {}), + providerMetadata: { + traceId: payload.trace_id, + status: payload.data.status, + responseFormat, + region, + channelCount: payload.extra_info?.music_channel, + bitrate: payload.extra_info?.bitrate, + sizeBytes: payload.extra_info?.music_size, + }, + }; + + return { + created: Math.floor(Date.now() / 1000), + modelId: model, + providerId: this.providerId, + audio: [generatedAudio], + usage: { totalAudioClips: 1 }, + }; + } + + supports(capability: 'music' | 'sfx'): boolean { + return capability === 'music'; + } + + async shutdown(): Promise { + this.isInitialized = false; + } +} diff --git a/src/io/media/audio/types.ts b/src/io/media/audio/types.ts index 8d5ead288ee..58a0d863f78 100644 --- a/src/io/media/audio/types.ts +++ b/src/io/media/audio/types.ts @@ -24,6 +24,7 @@ /** Well-known audio provider identifiers. Extensible via `(string & {})`. */ export type AudioProviderId = + | 'minimax-music' | 'suno' | 'udio' | 'stable-audio'