Skip to content
Merged
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
80 changes: 74 additions & 6 deletions src/commands/music/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,28 +15,87 @@ export default defineCommand({
description: 'Generate a song (music-2.5)',
usage: 'minimax music generate --prompt <text> [--lyrics <text>] [--out <path>] [flags]',
options: [
{ flag: '--prompt <text>', description: 'Music style description' },
{ flag: '--lyrics <text>', description: 'Song lyrics' },
{ flag: '--lyrics-file <path>', description: 'Read lyrics from file (use - for stdin)' },
{ flag: '--prompt <text>', description: 'Music style description (can be detailed — see examples)' },
{ flag: '--lyrics <text>', description: 'Song lyrics with structure tags. Use "无歌词" for instrumental music. Cannot be used with --instrumental.' },
{ flag: '--lyrics-file <path>', description: 'Read lyrics from file. Use "无歌词" for instrumental. (use - for stdin)' },
{ flag: '--vocals <text>', description: 'Vocal style, e.g. "warm male baritone", "bright female soprano", "duet with harmonies"' },
{ flag: '--genre <text>', description: 'Music genre, e.g. folk, pop, jazz' },
{ flag: '--mood <text>', description: 'Mood or emotion, e.g. warm, melancholic, uplifting' },
{ flag: '--instruments <text>', description: 'Instruments to feature, e.g. "acoustic guitar, piano"' },
{ flag: '--tempo <text>', description: 'Tempo description, e.g. fast, slow, moderate' },
{ flag: '--bpm <number>', description: 'Exact tempo in beats per minute', type: 'number' },
{ flag: '--key <text>', description: 'Musical key, e.g. C major, A minor, G sharp' },
{ flag: '--avoid <text>', description: 'Elements to avoid in the generated music' },
{ flag: '--use-case <text>', description: 'Use case context, e.g. "background music for video", "theme song"' },
{ flag: '--structure <text>', description: 'Song structure, e.g. "verse-chorus-verse-bridge-chorus"' },
{ flag: '--references <text>', description: 'Reference tracks or artists, e.g. "similar to Ed Sheeran, Taylor Swift"' },
{ flag: '--extra <text>', description: 'Additional fine-grained requirements not covered above' },
{ flag: '--instrumental', description: 'Generate instrumental music (no vocals)' },
{ flag: '--aigc-watermark', description: 'Embed AI-generated content watermark in audio for content provenance' },
{ flag: '--format <fmt>', description: 'Audio format (default: mp3)' },
{ flag: '--sample-rate <hz>', description: 'Sample rate (default: 44100)', type: 'number' },
{ flag: '--bitrate <bps>', description: 'Bitrate (default: 256000)', type: 'number' },
{ flag: '--stream', description: 'Stream raw audio to stdout' },
{ flag: '--out <path>', description: 'Save audio to file (uses hex decoding)' },
],
examples: [
'minimax music generate --prompt "Upbeat pop" --lyrics "La la la..."',
'minimax music generate --prompt "Indie folk, melancholic" --lyrics-file song.txt --out my_song.mp3',
'minimax music generate --prompt "Upbeat pop" --lyrics "La la la..." --out summer.mp3',
'minimax music generate --prompt "Indie folk, melancholic" --lyrics-file song.txt --out my_song.mp3',
'# Detailed prompt with vocal characteristics — music-2.5 responds well to rich descriptions:',
'minimax music generate --prompt "Warm morning folk" --vocals "male and female duet, harmonies in chorus" --instruments "acoustic guitar, piano" --bpm 95 --lyrics-file song.txt --out duet.mp3',
'# Instrumental (use --instrumental flag):',
'minimax music generate --prompt "Cinematic orchestral, building tension" --instrumental --out bgm.mp3',
'# Or specify "无歌词" in lyrics:',
'minimax music generate --prompt "Cinematic orchestral" --lyrics "无歌词" --out bgm.mp3',
],
async run(config: Config, flags: GlobalFlags) {
const prompt = flags.prompt as string | undefined;
let prompt = flags.prompt as string | undefined;
let lyrics = flags.lyrics as string | undefined;

if (flags.lyricsFile) {
lyrics = readTextFromPathOrStdin(flags.lyricsFile as string);
}

// Check for conflicting flags: --instrumental and --lyrics/--lyrics-file
if (flags.instrumental && (lyrics || flags.lyricsFile)) {
throw new CLIError(
'Cannot use --instrumental with --lyrics or --lyrics-file. For instrumental music, simply use --instrumental without --lyrics.',
ExitCode.USAGE,
'minimax music generate --prompt <style> --instrumental',
);
}

// Build structured prompt from optional music characteristic flags.
// music-2.5 interprets rich natural-language prompts — these flags make it
// easy to describe vocal style, genre, mood, and instrumentation without
// needing to hand-craft a long --prompt string.
const structuredParts: string[] = [];
if (flags.vocals) structuredParts.push(`Vocals: ${flags.vocals as string}`);
if (flags.genre) structuredParts.push(`Genre: ${flags.genre as string}`);
if (flags.mood) structuredParts.push(`Mood: ${flags.mood as string}`);
if (flags.instruments) structuredParts.push(`Instruments: ${flags.instruments as string}`);
if (flags.tempo) structuredParts.push(`Tempo: ${flags.tempo as string}`);
if (flags.bpm) structuredParts.push(`BPM: ${flags.bpm as number}`);
if (flags.key) structuredParts.push(`Key: ${flags.key as string}`);
if (flags.avoid) structuredParts.push(`Avoid: ${flags.avoid as string}`);
if (flags.useCase) structuredParts.push(`Use case: ${flags.useCase as string}`);
if (flags.structure) structuredParts.push(`Structure: ${flags.structure as string}`);
if (flags.references) structuredParts.push(`References: ${flags.references as string}`);
if (flags.extra) structuredParts.push(`Extra: ${flags.extra as string}`);

// Handle "无歌词" as instrumental request
if (lyrics === '无歌词' || lyrics === 'no lyrics') {
lyrics = '[intro] [outro]';
structuredParts.push('Style: instrumental, no vocals, pure music');
}

// Handle --instrumental: music-2.5 has no is_instrumental flag,
// so we use the empty-structure lyrics workaround.
if (flags.instrumental) {
lyrics = '[intro] [outro]';
structuredParts.push('Style: instrumental, no vocals, pure music');
}

if (!prompt && !lyrics) {
throw new CLIError(
'At least one of --prompt or --lyrics is required.',
Expand All @@ -49,6 +108,11 @@ export default defineCommand({
process.stderr.write('Warning: No lyrics provided. Use --lyrics or --lyrics-file to include lyrics.\n');
}

if (structuredParts.length > 0) {
const structured = structuredParts.join('. ');
prompt = prompt ? `${prompt}. ${structured}` : structured;
}

const outPath = flags.out as string | undefined;
const outFormat = outPath ? 'hex' : 'url';
const format = detectOutputFormat(config.output);
Expand All @@ -66,6 +130,10 @@ export default defineCommand({
stream: flags.stream === true,
};

if (flags.aigcWatermark) {
body.aigc_watermark = true;
}

if (config.dryRun) {
console.log(formatOutput({ request: body }, format));
return;
Expand Down
1 change: 1 addition & 0 deletions src/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ export interface MusicRequest {
};
output_format?: 'url' | 'hex';
stream?: boolean;
aigc_watermark?: boolean;
}

export interface MusicResponse {
Expand Down
150 changes: 125 additions & 25 deletions test/commands/music/generate.test.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,138 @@
import { describe, it, expect } from 'bun:test';
import { default as generateCommand } from '../../../src/commands/music/generate';

const baseConfig = {
apiKey: 'test-key',
region: 'global' as const,
baseUrl: 'https://api.minimax.io',
output: 'text' as const,
timeout: 10,
verbose: false,
quiet: false,
noColor: true,
yes: false,
dryRun: false,
nonInteractive: true,
async: false,
};

const baseFlags = {
quiet: false,
verbose: false,
noColor: true,
yes: false,
dryRun: false,
help: false,
nonInteractive: true,
async: false,
};

describe('music generate command', () => {
it('has correct name', () => {
expect(generateCommand.name).toBe('music generate');
});

it('requires prompt or lyrics', async () => {
const config = {
apiKey: 'test-key',
region: 'global' as const,
baseUrl: 'https://api.minimax.io',
output: 'text' as const,
timeout: 10,
verbose: false,
quiet: false,
noColor: true,
yes: false,
dryRun: false,
nonInteractive: false,
async: false,
};

await expect(
generateCommand.execute(config, {
quiet: false,
verbose: false,
noColor: true,
yes: false,
dryRun: false,
help: false,
nonInteractive: false,
async: false,
}),
generateCommand.execute(baseConfig, baseFlags),
).rejects.toThrow('At least one of --prompt or --lyrics is required');
});

it('structured flags are appended to prompt (dry-run)', async () => {
// Use dryRun=true so no real API call is made.
let resolved = false;
try {
await generateCommand.execute(
{ ...baseConfig, dryRun: true, output: 'json' as const },
{
...baseFlags,
dryRun: true,
prompt: 'Indie folk',
vocals: 'warm male and bright female duet',
genre: 'folk',
mood: 'warm',
instruments: 'acoustic guitar, piano',
bpm: 95,
avoid: 'electronic beats',
},
);
resolved = true;
} catch (_) {
// dryRun may resolve or reject depending on output routing; either is fine
resolved = true;
}
expect(resolved).toBe(true);
});

it('has all structured flags defined: vocals, genre, mood, instruments, tempo, bpm, key, use-case, structure, references, avoid, extra, instrumental, aigc-watermark', () => {
const optionFlags = generateCommand.options?.map((o) => o.flag) ?? [];
expect(optionFlags.some((f) => f.startsWith('--vocals'))).toBe(true);
expect(optionFlags.some((f) => f.startsWith('--genre'))).toBe(true);
expect(optionFlags.some((f) => f.startsWith('--mood'))).toBe(true);
expect(optionFlags.some((f) => f.startsWith('--instruments'))).toBe(true);
expect(optionFlags.some((f) => f.startsWith('--tempo'))).toBe(true);
expect(optionFlags.some((f) => f.startsWith('--bpm'))).toBe(true);
expect(optionFlags.some((f) => f.startsWith('--key'))).toBe(true);
expect(optionFlags.some((f) => f.startsWith('--use-case'))).toBe(true);
expect(optionFlags.some((f) => f.startsWith('--structure'))).toBe(true);
expect(optionFlags.some((f) => f.startsWith('--references'))).toBe(true);
expect(optionFlags.some((f) => f.startsWith('--avoid'))).toBe(true);
expect(optionFlags.some((f) => f.startsWith('--extra'))).toBe(true);
expect(optionFlags.some((f) => f.startsWith('--instrumental'))).toBe(true);
expect(optionFlags.some((f) => f.startsWith('--aigc-watermark'))).toBe(true);
});

it('examples include vocal and instrumental usage', () => {
const examples = generateCommand.examples ?? [];
const joined = examples.join(' ');
expect(joined).toContain('vocals');
expect(joined).toContain('--instrumental');
expect(joined).toContain('无歌词');
});

it('rejects --instrumental with --lyrics', async () => {
await expect(
generateCommand.execute(
{ ...baseConfig, dryRun: true },
{ ...baseFlags, prompt: 'Folk', instrumental: true, lyrics: 'Hello' },
),
).rejects.toThrow('Cannot use --instrumental with --lyrics');
});

it('rejects --instrumental with --lyrics-file', async () => {
await expect(
generateCommand.execute(
{ ...baseConfig, dryRun: true },
{ ...baseFlags, prompt: 'Folk', instrumental: true, lyricsFile: '/dev/null' },
),
).rejects.toThrow('Cannot use --instrumental with --lyrics');
});

it('handles "无歌词" as instrumental', async () => {
let resolved = false;
try {
await generateCommand.execute(
{ ...baseConfig, dryRun: true, output: 'json' as const },
{ ...baseFlags, dryRun: true, prompt: 'Folk', lyrics: '无歌词' },
);
resolved = true;
} catch (_) {
resolved = true;
}
expect(resolved).toBe(true);
});

it('handles "no lyrics" (English) as instrumental', async () => {
let resolved = false;
try {
await generateCommand.execute(
{ ...baseConfig, dryRun: true, output: 'json' as const },
{ ...baseFlags, dryRun: true, prompt: 'Folk', lyrics: 'no lyrics' },
);
resolved = true;
} catch (_) {
resolved = true;
}
expect(resolved).toBe(true);
});
});