diff --git a/SDK.md b/SDK.md index 41a14562..4dbf3528 100644 --- a/SDK.md +++ b/SDK.md @@ -118,7 +118,7 @@ const music = await sdk.music.generate({ // Instrumental const instrumental = await sdk.music.generate({ prompt: 'Cinematic orchestral', - instrumental: true, + is_instrumental: true, }); // Auto-generate lyrics @@ -135,7 +135,7 @@ const stream = await sdk.music.generate({ }); for await (const chunk of stream) { - // process audio chunks + // chunk contains decoded audio bytes } // Structured prompt diff --git a/src/commands/music/cover.ts b/src/commands/music/cover.ts index b65f3f59..9590c9f4 100644 --- a/src/commands/music/cover.ts +++ b/src/commands/music/cover.ts @@ -11,15 +11,15 @@ import { pipeAudioStream } from '../../utils/audio-stream'; import type { Config } from '../../config/schema'; import type { GlobalFlags } from '../../types/flags'; import type { CoverPreprocessRequest, CoverPreprocessResponse, MusicRequest, MusicResponse } from '../../types/api'; -import { musicCoverModel } from './models'; +import { MUSIC_COVER_MODELS, musicCoverModel } from './models'; export default defineCommand({ name: 'music cover', - description: 'Generate a cover version of a song based on reference audio (music-cover)', + description: 'Generate a cover version of a song based on reference audio', apiDocs: '/docs/api-reference/music-generation', usage: 'mmx music cover --prompt (--audio | --audio-file ) [--lyrics ] [--out ] [flags]', options: [ - { flag: '--model ', description: 'Model: music-cover (default).' }, + { flag: '--model ', description: 'Model: music-cover (default) or music-cover-free.' }, { flag: '--prompt ', description: 'Target cover style, e.g. "Indie folk, acoustic guitar, warm male vocal"' }, { flag: '--audio ', description: 'URL of the reference audio (mp3, wav, flac, etc. — 6s to 6min, max 50MB)' }, { flag: '--audio-file ', description: 'Local reference audio file (auto base64-encoded)' }, @@ -37,6 +37,7 @@ export default defineCommand({ 'mmx music cover --prompt "Indie folk, acoustic guitar, warm male vocal" --audio https://example.com/song.mp3 --out cover.mp3', 'mmx music cover --prompt "Jazz, piano, slow" --audio-file original.mp3 --lyrics-file lyrics.txt --out jazz_cover.mp3', 'mmx music cover --prompt "Pop, upbeat" --audio https://example.com/ref.mp3 --seed 42 --out reproducible.mp3', + 'mmx music cover --model music-cover-free --prompt "Jazz, piano, slow" --audio https://example.com/ref.mp3 --out free_cover.mp3', ], async run(config: Config, flags: GlobalFlags) { const prompt = flags.prompt as string | undefined; @@ -72,10 +73,9 @@ export default defineCommand({ const format = detectOutputFormat(config.output); const model = (flags.model as string) || musicCoverModel(config); - const VALID_MODELS = ['music-cover']; - if (flags.model && !VALID_MODELS.includes(model)) { + if (flags.model && !MUSIC_COVER_MODELS.includes(model as typeof MUSIC_COVER_MODELS[number])) { throw new CLIError( - `Invalid model "${model}". Valid models: ${VALID_MODELS.join(', ')}`, + `Invalid model "${model}". Valid models: ${MUSIC_COVER_MODELS.join(', ')}`, ExitCode.USAGE, 'mmx music cover --model music-cover', ); diff --git a/src/commands/music/generate.ts b/src/commands/music/generate.ts index 5d6c685e..58f7acb3 100644 --- a/src/commands/music/generate.ts +++ b/src/commands/music/generate.ts @@ -11,11 +11,11 @@ import { pipeAudioStream } from '../../utils/audio-stream'; import type { Config } from '../../config/schema'; import type { GlobalFlags } from '../../types/flags'; import type { MusicRequest, MusicResponse } from '../../types/api'; -import { musicGenerateModel } from './models'; +import { MUSIC_GENERATE_MODELS, musicGenerateModel } from './models'; export default defineCommand({ name: 'music generate', - description: 'Generate a song (music-2.6 / music-2.5+ / music-2.5)', + description: 'Generate a song (music-2.6, including free tier)', apiDocs: '/docs/api-reference/music-generation', usage: 'mmx music generate --prompt (--lyrics | --instrumental | --lyrics-optimizer) [--out ] [flags]', options: [ @@ -36,7 +36,7 @@ export default defineCommand({ { flag: '--structure ', description: 'Song structure, e.g. "verse-chorus-verse-bridge-chorus"' }, { flag: '--references ', description: 'Reference tracks or artists, e.g. "similar to Ed Sheeran"' }, { flag: '--extra ', description: 'Additional fine-grained requirements not covered above' }, - { flag: '--model ', description: 'Model: music-2.6 (default), music-2.5+, or music-2.5.' }, + { flag: '--model ', description: 'Model: music-2.6 (default), music-2.6-free, music-2.5+, or music-2.5.' }, { flag: '--output-format ', description: 'Return format: hex (default, saved to file) or url (24h expiry, download promptly). When --stream, only hex.' }, { flag: '--aigc-watermark', description: 'Embed AI-generated content watermark in audio for content provenance' }, { flag: '--format ', description: `Audio format: ${formatList(MUSIC_FORMATS)} (default: mp3)` }, @@ -93,6 +93,14 @@ export default defineCommand({ ); } + if ((isInstrumental || lyricsOptimizer) && !prompt?.trim()) { + throw new CLIError( + '--prompt is required with --instrumental or --lyrics-optimizer.', + ExitCode.USAGE, + 'mmx music generate --prompt --instrumental', + ); + } + if (!isInstrumental && !lyricsOptimizer && !lyrics?.trim()) { throw new CLIError( 'Lyrics are required. Add --lyrics or --lyrics-file, or use --instrumental for pure music, or --lyrics-optimizer to auto-generate.', @@ -127,10 +135,9 @@ export default defineCommand({ const outPath = (flags.out as string | undefined) ?? `music_${ts}.${ext}`; const model = (flags.model as string) || musicGenerateModel(config); - const VALID_MODELS = ['music-2.6', 'music-2.5+', 'music-2.5']; - if (flags.model && !VALID_MODELS.includes(model)) { + if (flags.model && !MUSIC_GENERATE_MODELS.includes(model as typeof MUSIC_GENERATE_MODELS[number])) { throw new CLIError( - `Invalid model "${model}". Valid models: ${VALID_MODELS.join(', ')}`, + `Invalid model "${model}". Valid models: ${MUSIC_GENERATE_MODELS.join(', ')}`, ExitCode.USAGE, 'mmx music generate --model music-2.6', ); diff --git a/src/commands/music/models.ts b/src/commands/music/models.ts index 73b25b2b..2898deb0 100644 --- a/src/commands/music/models.ts +++ b/src/commands/music/models.ts @@ -1,13 +1,33 @@ import type { Config } from '../../config/schema'; -export function musicGenerateModel(config: Config): string { - return config.defaultMusicModel ?? 'music-2.6'; +export const MUSIC_GENERATE_MODELS = [ + 'music-2.6', + 'music-2.6-free', + 'music-2.5+', + 'music-2.5', +] as const; + +export const MUSIC_COVER_MODELS = ['music-cover', 'music-cover-free'] as const; + +function includesModel(models: readonly string[], model: string): boolean { + return models.includes(model); } -const VALID_COVER_MODELS = new Set(['music-cover']); +export function musicGenerateModel(config: Config): string { + if ( + config.defaultMusicModel + && includesModel(MUSIC_GENERATE_MODELS, config.defaultMusicModel) + ) { + return config.defaultMusicModel; + } + return 'music-2.6'; +} export function musicCoverModel(config: Config): string { - if (config.defaultMusicModel && VALID_COVER_MODELS.has(config.defaultMusicModel)) { + if ( + config.defaultMusicModel + && includesModel(MUSIC_COVER_MODELS, config.defaultMusicModel) + ) { return config.defaultMusicModel; } return 'music-cover'; diff --git a/src/sdk/music/index.ts b/src/sdk/music/index.ts index b2f55fed..d2c79f36 100644 --- a/src/sdk/music/index.ts +++ b/src/sdk/music/index.ts @@ -7,7 +7,8 @@ import { ModelPartial } from "../types"; import { SDKError } from "../../errors/base"; import { ExitCode } from "../../errors/codes"; import { toMerged } from "es-toolkit/object"; -import { musicGenerateModel } from "../../commands/music/models"; +import { MUSIC_GENERATE_MODELS, musicGenerateModel } from "../../commands/music/models"; +import { decodeAudioStream } from "../../utils/audio-stream"; function hexToBuffer(hex: string): Buffer { if (!/^[0-9a-fA-F]*$/.test(hex)) { @@ -64,20 +65,11 @@ export class MusicSDK extends Client { stream: true, }); - const reader = res.body?.getReader(); - if (!reader) { + if (!res.body) { throw new SDKError('No response body', ExitCode.GENERAL); - }; - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - yield value; - } - } finally { - reader.releaseLock(); } + + yield* decodeAudioStream(res); } async generate(request: ModelPartial & { stream: true }): Promise>>; @@ -140,16 +132,15 @@ export class MusicSDK extends Client { if (request.bpm) structuredParts.push(`BPM: ${request.bpm as number}`); if (request.key) structuredParts.push(`Key: ${request.key as string}`); if (request.avoid) structuredParts.push(`Avoid: ${request.avoid as string}`); - if (request.useCase) structuredParts.push(`Use case: ${request.useCase as string}`); + const useCase = request.useCase || request.use_case; + if (useCase) structuredParts.push(`Use case: ${useCase}`); if (request.structure) structuredParts.push(`Structure: ${request.structure as string}`); if (request.references) structuredParts.push(`References: ${request.references as string}`); if (request.extra) structuredParts.push(`Extra: ${request.extra as string}`); - let lyrics = request.lyrics; let prompt = request.prompt; - if (request.instrumental || !lyrics || lyrics === '无歌词' || lyrics === 'no lyrics') { - lyrics = '[intro] [outro]'; + if (request.is_instrumental || request.instrumental) { structuredParts.push('Style: instrumental, no vocals, pure music'); } @@ -161,9 +152,43 @@ export class MusicSDK extends Client { } private validateParams(params: ModelPartial) { + const instrumental = params.instrumental; + const apiParams = { ...params }; + const sdkOnlyFields: Array = [ + 'instrumental', + 'vocals', + 'genre', + 'mood', + 'instruments', + 'tempo', + 'bpm', + 'key', + 'avoid', + 'use_case', + 'useCase', + 'structure', + 'references', + 'extra', + ]; + for (const field of sdkOnlyFields) delete apiParams[field]; + if ( + instrumental !== undefined + && params.is_instrumental !== undefined + && instrumental !== params.is_instrumental + ) { + throw new SDKError( + 'instrumental and is_instrumental must not conflict', + ExitCode.USAGE, + ); + } + + const normalized: ModelPartial = { + ...apiParams, + is_instrumental: params.is_instrumental ?? instrumental, + }; const { model, output_format, stream, prompt, lyrics, is_instrumental, lyrics_optimizer, - } = params; + } = normalized; if (is_instrumental && lyrics) { throw new SDKError('Cannot use is_instrumental with lyrics', ExitCode.USAGE); } @@ -176,14 +201,20 @@ export class MusicSDK extends Client { throw new SDKError('At least one of prompt or lyrics or is_instrumental or lyrics_optimizer is required', ExitCode.USAGE); } + if ((is_instrumental || lyrics_optimizer) && !prompt?.trim()) { + throw new SDKError( + 'prompt is required with is_instrumental or lyrics_optimizer', + ExitCode.USAGE, + ); + } + if (!is_instrumental && !lyrics_optimizer && !lyrics?.trim()) { throw new SDKError('lyrics is required', ExitCode.USAGE); } - const VALID_MODELS = ['music-2.6', 'music-2.5+', 'music-2.5']; - if (model && !VALID_MODELS.includes(model)) { + if (model && !MUSIC_GENERATE_MODELS.includes(model as typeof MUSIC_GENERATE_MODELS[number])) { throw new SDKError( - `Invalid model: ${model}. Valid models are ${VALID_MODELS.join(', ')}.`, + `Invalid model: ${model}. Valid models are ${MUSIC_GENERATE_MODELS.join(', ')}.`, ExitCode.USAGE, ); } @@ -202,7 +233,10 @@ export class MusicSDK extends Client { ); } - const targetPrompt = this.buildPrompt(params); + const targetPrompt = this.buildPrompt({ + ...params, + is_instrumental, + }); return toMerged({ model: musicGenerateModel(this.config), @@ -213,7 +247,7 @@ export class MusicSDK extends Client { }, output_format: 'hex', }, { - ...params, + ...normalized, prompt: targetPrompt, }); } diff --git a/src/utils/audio-stream.ts b/src/utils/audio-stream.ts index 1ff57c01..06c4c22a 100644 --- a/src/utils/audio-stream.ts +++ b/src/utils/audio-stream.ts @@ -4,12 +4,9 @@ interface SseAudioPayload { data?: { audio?: string; status?: number }; } -export async function pipeAudioStream(response: Response): Promise { - process.stdout.on('error', (err: NodeJS.ErrnoException) => { - if (err.code === 'EPIPE') process.exit(0); - throw err; - }); - +export async function* decodeAudioStream( + response: Response, +): AsyncGenerator> { for await (const event of parseSSE(response)) { if (!event.data || event.data === '[DONE]') break; @@ -21,7 +18,17 @@ export async function pipeAudioStream(response: Response): Promise { const hex = parsed.data?.audio; if (!hex) continue; - const chunk = Buffer.from(hex, 'hex'); + yield Uint8Array.from(Buffer.from(hex, 'hex')); + } +} + +export async function pipeAudioStream(response: Response): Promise { + process.stdout.on('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'EPIPE') process.exit(0); + throw err; + }); + + for await (const chunk of decodeAudioStream(response)) { if (!process.stdout.write(chunk)) { await new Promise(r => process.stdout.once('drain', r)); } diff --git a/test/commands/music/cover.test.ts b/test/commands/music/cover.test.ts index 05d8feda..12db1c5f 100644 --- a/test/commands/music/cover.test.ts +++ b/test/commands/music/cover.test.ts @@ -151,6 +151,29 @@ describe('music cover command', () => { ).rejects.toThrow('Invalid model'); }); + it('accepts the official music-cover-free model', async () => { + let captured = ''; + const origLog = console.log; + console.log = (msg: string) => { captured += msg; }; + + try { + await coverCommand.execute( + { ...baseConfig, dryRun: true, output: 'json' as const }, + { + ...baseFlags, + dryRun: true, + prompt: 'Folk cover', + audio: 'https://example.com/ref.mp3', + model: 'music-cover-free', + }, + ); + } finally { + console.log = origLog; + } + + expect(JSON.parse(captured).request.model).toBe('music-cover-free'); + }); + it('rejects invalid audio format', async () => { await expect( coverCommand.execute( diff --git a/test/commands/music/generate.test.ts b/test/commands/music/generate.test.ts index e5d0b160..aa6c9cf0 100644 --- a/test/commands/music/generate.test.ts +++ b/test/commands/music/generate.test.ts @@ -181,6 +181,38 @@ describe('music generate command', () => { expect(parsed.request.model).toBe('music-2.5'); }); + it('accepts the official music-2.6-free model', async () => { + let captured = ''; + const origLog = console.log; + console.log = (msg: string) => { captured += msg; }; + + try { + await generateCommand.execute( + { ...baseConfig, dryRun: true, output: 'json' as const }, + { + ...baseFlags, + dryRun: true, + prompt: 'Folk', + lyrics: 'la la', + model: 'music-2.6-free', + }, + ); + } finally { + console.log = origLog; + } + + expect(JSON.parse(captured).request.model).toBe('music-2.6-free'); + }); + + it('requires prompt for instrumental generation', async () => { + await expect( + generateCommand.execute( + { ...baseConfig, dryRun: true }, + { ...baseFlags, dryRun: true, instrumental: true }, + ), + ).rejects.toThrow('--prompt is required with --instrumental'); + }); + it('rejects invalid audio format', async () => { await expect( generateCommand.execute( diff --git a/test/commands/music/models.test.ts b/test/commands/music/models.test.ts index 9273ad26..4c2cb7c9 100644 --- a/test/commands/music/models.test.ts +++ b/test/commands/music/models.test.ts @@ -12,6 +12,11 @@ describe('music models', () => { expect(musicGenerateModel({} as Config)).toBe('music-2.6'); }); + it('musicGenerateModel accepts the free-tier model', () => { + const config = { defaultMusicModel: 'music-2.6-free' } as Config; + expect(musicGenerateModel(config)).toBe('music-2.6-free'); + }); + it('musicCoverModel ignores defaultMusicModel for non-cover models', () => { const config = { defaultMusicModel: 'music-2.6' } as Config; expect(musicCoverModel(config)).toBe('music-cover'); @@ -22,6 +27,11 @@ describe('music models', () => { expect(musicCoverModel(config)).toBe('music-cover'); }); + it('musicCoverModel accepts the free-tier cover model', () => { + const config = { defaultMusicModel: 'music-cover-free' } as Config; + expect(musicCoverModel(config)).toBe('music-cover-free'); + }); + it('musicCoverModel defaults to music-cover', () => { expect(musicCoverModel({} as Config)).toBe('music-cover'); }); diff --git a/test/sdk/music.test.ts b/test/sdk/music.test.ts index 87a9505a..ccde5632 100644 --- a/test/sdk/music.test.ts +++ b/test/sdk/music.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, afterEach } from 'bun:test'; -import { createMockServer, jsonResponse, type MockServer } from '../helpers/mock-server'; +import { createMockServer, jsonResponse, sseResponse, type MockServer } from '../helpers/mock-server'; import { MiniMaxSDK } from '../../src/sdk'; -import { MusicSDK } from '../../src/sdk/music'; +import { MusicSDK, type MusicGenerateRequest } from '../../src/sdk/music'; import { existsSync, unlinkSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; @@ -24,13 +24,17 @@ describe('MiniMaxSDK.music', () => { server?.close(); }); - it('should generate music successfully', async () => { + it('maps the instrumental alias to the official field and accepts the free model', async () => { + let requestBody: Record | undefined; server = createMockServer({ routes: { - '/v1/music_generation': () => jsonResponse({ - data: { audio_url: 'https://example.com/music.mp3' }, - base_resp: { status_code: 0, status_msg: 'success' }, - }), + '/v1/music_generation': async (req) => { + requestBody = await req.json() as Record; + return jsonResponse({ + data: { audio_url: 'https://example.com/music.mp3' }, + base_resp: { status_code: 0, status_msg: 'success' }, + }); + }, }, }); @@ -40,11 +44,44 @@ describe('MiniMaxSDK.music', () => { }); const result = await sdk.music.generate({ - lyrics: 'no lyrics', + model: 'music-2.6-free', + prompt: 'Cinematic orchestral', + genre: 'soundtrack', instrumental: true, }); expect(result.data.audio_url).toBe('https://example.com/music.mp3'); + expect(requestBody?.model).toBe('music-2.6-free'); + expect(requestBody?.is_instrumental).toBe(true); + expect(requestBody?.instrumental).toBeUndefined(); + expect(requestBody?.genre).toBeUndefined(); + expect(requestBody?.prompt).toContain('Genre: soundtrack'); + }); + + it('decodes SSE hex payloads into streaming audio bytes', async () => { + server = createMockServer({ + routes: { + '/v1/music_generation': () => sseResponse([ + { data: JSON.stringify({ data: { audio: '4142', status: 1 } }) }, + { data: JSON.stringify({ data: { audio: '4344', status: 1 } }) }, + { data: JSON.stringify({ data: { status: 2 } }) }, + ]), + }, + }); + + const sdk = new MiniMaxSDK({ + apiKey: 'test-key', + baseUrl: server.url, + }); + const stream = await sdk.music.generate({ + prompt: 'Upbeat pop', + lyrics: '[verse] Hello world', + stream: true, + }); + const chunks: Uint8Array[] = []; + for await (const chunk of stream) chunks.push(chunk); + + expect(Buffer.concat(chunks).toString()).toBe('ABCD'); }); }); @@ -90,49 +127,69 @@ describe('MusicSDK.validateParams', () => { it('throws when is_instrumental and lyrics are both provided', async () => { await expect( - sdk.generate({ is_instrumental: true, lyrics: 'hello' } as any), + sdk.generate({ is_instrumental: true, lyrics: 'hello' }), ).rejects.toThrow('Cannot use is_instrumental with lyrics'); }); it('throws when lyrics_optimizer is used with lyrics', async () => { await expect( - sdk.generate({ lyrics_optimizer: true, lyrics: 'hello' } as any), + sdk.generate({ lyrics_optimizer: true, lyrics: 'hello' }), ).rejects.toThrow('Cannot use lyrics_optimizer with lyrics or is_instrumental'); }); it('throws when lyrics_optimizer is used with is_instrumental', async () => { await expect( - sdk.generate({ lyrics_optimizer: true, is_instrumental: true } as any), + sdk.generate({ lyrics_optimizer: true, is_instrumental: true }), ).rejects.toThrow('Cannot use lyrics_optimizer with lyrics or is_instrumental'); }); it('throws when no prompt, lyrics, is_instrumental, or lyrics_optimizer', async () => { await expect( - sdk.generate({} as any), + sdk.generate({}), ).rejects.toThrow('At least one of prompt or lyrics or is_instrumental or lyrics_optimizer is required'); }); it('throws when lyrics is missing (not is_instrumental, not lyrics_optimizer)', async () => { await expect( - sdk.generate({ prompt: 'Upbeat pop' } as any), + sdk.generate({ prompt: 'Upbeat pop' }), ).rejects.toThrow('lyrics is required'); }); + it('requires prompt for instrumental generation', async () => { + await expect( + sdk.generate({ is_instrumental: true }), + ).rejects.toThrow('prompt is required with is_instrumental'); + }); + + it('rejects conflicting instrumental aliases', async () => { + await expect( + sdk.generate({ + prompt: 'Cinematic', + instrumental: true, + is_instrumental: false, + }), + ).rejects.toThrow('instrumental and is_instrumental must not conflict'); + }); + it('throws on invalid model', async () => { await expect( - sdk.generate({ prompt: 'Folk', lyrics: 'no lyrics', model: 'music-2.0' } as any), + sdk.generate({ prompt: 'Folk', lyrics: 'no lyrics', model: 'music-2.0' }), ).rejects.toThrow('Invalid model'); }); it('throws on invalid output_format', async () => { await expect( - sdk.generate({ prompt: 'Folk', lyrics: 'no lyrics', output_format: 'mp3' } as any), + sdk.generate({ + prompt: 'Folk', + lyrics: 'no lyrics', + output_format: 'mp3' as MusicGenerateRequest['output_format'], + }), ).rejects.toThrow('Invalid output format'); }); it('throws when stream and output_format=url are used together', async () => { await expect( - sdk.generate({ prompt: 'Folk', lyrics: 'no lyrics', stream: true, output_format: 'url' } as any), + sdk.generate({ prompt: 'Folk', lyrics: 'no lyrics', stream: true, output_format: 'url' }), ).rejects.toThrow('stream and output_format url cannot be used together'); }); });