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: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ mmx speech voices
mmx music generate --prompt "Upbeat pop" --lyrics "[verse] La da dee, sunny day" --out song.mp3
# Auto-generate lyrics from prompt
mmx music generate --prompt "Indie folk, melancholic, rainy night" --lyrics-optimizer --out song.mp3
# Save lyrics to a custom path (defaults to current directory)
mmx music generate --prompt "Indie folk, melancholic, rainy night" --lyrics-optimizer --out song.mp3 --lyrics-out ./lyrics/
# Instrumental (no vocals)
mmx music generate --prompt "Cinematic orchestral" --instrumental --out bgm.mp3
# Cover — generate a cover version from a reference audio file
Expand Down
4 changes: 4 additions & 0 deletions src/client/endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ export function musicEndpoint(baseUrl: string): string {
return `${baseUrl}/v1/music_generation`;
}

export function lyricsGenerationEndpoint(baseUrl: string): string {
return `${baseUrl}/v1/lyrics_generation`;
}

export function searchEndpoint(baseUrl: string): string {
return `${baseUrl}/v1/coding_plan/search`;
}
Expand Down
89 changes: 85 additions & 4 deletions src/commands/music/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,59 @@ import { defineCommand } from '../../command';
import { CLIError } from '../../errors/base';
import { ExitCode } from '../../errors/codes';
import { request, requestJson } from '../../client/http';
import { musicEndpoint } from '../../client/endpoints';
import { detectOutputFormat, dryRun } from '../../output/formatter';
import { existsSync, mkdirSync, statSync, writeFileSync } from 'node:fs';
import { basename, dirname, extname, join, resolve } from 'node:path';
import { lyricsGenerationEndpoint, musicEndpoint } from '../../client/endpoints';
import { detectOutputFormat, dryRun, formatOutput } from '../../output/formatter';
import { saveAudioOutput } from '../../output/audio';
import { readTextFromPathOrStdin } from '../../utils/fs';
import { MUSIC_FORMATS, formatList, validateAudioFormat } from '../../utils/audio-formats';
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 type { LyricsGenerationRequest, LyricsGenerationResponse, MusicRequest, MusicResponse } from '../../types/api';
import { musicGenerateModel } from './models';

function defaultLyricsFilename(audioOutPath: string): string {
return `${basename(audioOutPath, extname(audioOutPath))}.lyrics.txt`;
}

function resolveLyricsOutputPath(lyricsOut: string | undefined, audioOutPath: string): string {
const filename = defaultLyricsFilename(audioOutPath);

if (!lyricsOut) {
return resolve(filename);
}

const dest = resolve(lyricsOut);
const treatAsDirectory =
lyricsOut.endsWith('/') ||
lyricsOut.endsWith('\\') ||
(existsSync(dest) && statSync(dest).isDirectory());

return treatAsDirectory ? join(dest, filename) : dest;
}

function saveLyricsOutput(lyrics: string, lyricsOut: string | undefined, audioOutPath: string): string {
const dest = resolveLyricsOutputPath(lyricsOut, audioOutPath);
const dir = dirname(dest);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
writeFileSync(dest, lyrics, 'utf-8');
return dest;
}

export default defineCommand({
name: 'music generate',
description: 'Generate a song (music-2.6 / music-2.5+ / music-2.5)',
apiDocs: '/docs/api-reference/music-generation',
usage: 'mmx music generate --prompt <text> (--lyrics <text> | --instrumental | --lyrics-optimizer) [--out <path>] [flags]',
usage: 'mmx music generate --prompt <text> (--lyrics <text> | --instrumental | --lyrics-optimizer) [--out <path>] [--lyrics-out <path>] [flags]',
options: [
{ flag: '--prompt <text>', description: 'Music style description (e.g. "cinematic orchestral, building tension"). Max 2000 chars when combined with structured flags.' },
{ flag: '--lyrics <text>', description: 'Song lyrics with structure tags (newline separated). Supported: [Intro], [Verse], [Pre Chorus], [Chorus], [Interlude], [Bridge], [Outro], [Post Chorus], [Transition], [Break], [Hook], [Build Up], [Inst], [Solo]. Tags must be clean — no descriptions inside brackets (they will be sung). Max 3500 chars.' },
{ flag: '--lyrics-file <path>', description: 'Read lyrics from file (use - for stdin). Same tag rules as --lyrics.' },
{ flag: '--lyrics-out <path>', description: 'Save the resolved lyrics to a file or directory. Default: current directory as <audio-basename>.lyrics.txt.' },
{ flag: '--lyrics-optimizer', description: 'Auto-generate lyrics from prompt (cannot be used with --lyrics or --instrumental)' },
{ flag: '--instrumental', description: 'Generate instrumental music (no vocals). music-2.6/music-2.5+: native is_instrumental flag; music-2.5: lyrics workaround. Cannot be used with --lyrics.' },
{ flag: '--vocals <text>', description: 'Vocal style, e.g. "warm male baritone", "bright female soprano", "duet with harmonies"' },
Expand Down Expand Up @@ -50,6 +83,7 @@ export default defineCommand({
'mmx music generate --prompt "Indie folk, melancholic" --lyrics-file song.txt --out my_song.mp3',
'# Auto-generate lyrics from prompt:',
'mmx music generate --prompt "Upbeat pop about summer" --lyrics-optimizer --out summer.mp3',
'mmx music generate --prompt "Upbeat pop about summer" --lyrics-optimizer --out summer.mp3 --lyrics-out ./lyrics/',
'# Instrumental:',
'mmx music generate --prompt "Cinematic orchestral, building tension" --instrumental --out bgm.mp3',
'# URL output (24h expiry — download promptly):',
Expand Down Expand Up @@ -171,10 +205,41 @@ export default defineCommand({

const format = detectOutputFormat(config.output);
const url = musicEndpoint(config.baseUrl);
let resolvedLyrics = lyrics?.trim() ? lyrics : undefined;

if (lyricsOptimizer) {
const lyricsResponse = await requestJson<LyricsGenerationResponse>(config, {
url: lyricsGenerationEndpoint(config.baseUrl),
method: 'POST',
body: {
mode: 'write_full_song',
prompt,
} satisfies LyricsGenerationRequest,
});

if (!lyricsResponse.lyrics?.trim()) {
throw new CLIError(
'Lyrics optimizer did not return lyrics.',
ExitCode.GENERAL,
);
}

resolvedLyrics = lyricsResponse.lyrics;
body.lyrics = resolvedLyrics;
body.lyrics_optimizer = undefined;
}

if (flags.stream) {
const res = await request(config, { url, method: 'POST', body, stream: true });
await pipeAudioStream(res);
if (resolvedLyrics) {
const lyricsPath = saveLyricsOutput(resolvedLyrics, flags.lyricsOut as string | undefined, outPath);
if (config.quiet) {
console.log(lyricsPath);
} else {
console.log(formatOutput({ lyrics: lyricsPath }, format));
}
}
return;
}

Expand All @@ -194,8 +259,24 @@ export default defineCommand({
ExitCode.GENERAL,
);
}
if (resolvedLyrics) {
const lyricsPath = saveLyricsOutput(resolvedLyrics, flags.lyricsOut as string | undefined, outPath);
if (config.quiet) {
console.log(lyricsPath);
} else {
console.log(formatOutput({ lyrics: lyricsPath }, format));
}
}
return;
}
saveAudioOutput(response, outPath, format, config.quiet);
if (resolvedLyrics) {
const lyricsPath = saveLyricsOutput(resolvedLyrics, flags.lyricsOut as string | undefined, outPath);
if (config.quiet) {
console.log(lyricsPath);
} else {
console.log(formatOutput({ lyrics: lyricsPath }, format));
}
}
},
});
14 changes: 14 additions & 0 deletions src/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,20 @@ export interface MusicResponse {
};
}

export interface LyricsGenerationRequest {
mode: 'write_full_song' | 'edit';
prompt?: string;
lyrics?: string;
title?: string;
}

export interface LyricsGenerationResponse {
song_title?: string;
style_tags?: string;
lyrics: string;
base_resp: BaseResp;
}

// ---- Quota ----

export interface QuotaResponse {
Expand Down
1 change: 1 addition & 0 deletions src/types/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export interface MusicGenerateFlags {
prompt?: string;
lyrics?: string;
lyricsFile?: string;
lyricsOut?: string;
format?: string;
sampleRate?: number;
bitrate?: number;
Expand Down
135 changes: 134 additions & 1 deletion test/commands/music/generate.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { describe, it, expect } from 'bun:test';
import { afterEach, describe, expect, it } from 'bun:test';
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { default as generateCommand } from '../../../src/commands/music/generate';
import { createMockServer, jsonResponse, type MockServer } from '../../helpers/mock-server';

const baseConfig = {
apiKey: 'test-key',
Expand Down Expand Up @@ -28,6 +32,13 @@ const baseFlags = {
};

describe('music generate command', () => {
let server: MockServer | undefined;

afterEach(() => {
server?.close();
server = undefined;
});

it('has correct name', () => {
expect(generateCommand.name).toBe('music generate');
});
Expand Down Expand Up @@ -87,6 +98,7 @@ describe('music generate command', () => {
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);
expect(optionFlags.some((f) => f.startsWith('--lyrics-out'))).toBe(true);
});

it('examples include vocal, instrumental, and lyrics-optimizer usage', () => {
Expand Down Expand Up @@ -208,4 +220,125 @@ describe('music generate command', () => {
}
},
);

it('saves provided lyrics to the current directory by default', async () => {
server = createMockServer({
routes: {
'/v1/music_generation': () => jsonResponse({
base_resp: { status_code: 0, status_msg: 'ok' },
data: {
audio: Buffer.from('test music').toString('hex'),
status: 0,
},
}),
},
});

const tempDir = mkdtempSync(join(tmpdir(), 'mmx-music-generate-'));
const previousCwd = process.cwd();
const audioPath = join(tempDir, 'song.mp3');
const expectedLyricsPath = join(tempDir, 'song.lyrics.txt');

try {
process.chdir(tempDir);
await generateCommand.execute(
{ ...baseConfig, baseUrl: server.url, quiet: true },
{ ...baseFlags, quiet: true, prompt: 'Folk', lyrics: '[verse] hello world', out: audioPath },
);
expect(existsSync(expectedLyricsPath)).toBe(true);
expect(readFileSync(expectedLyricsPath, 'utf-8')).toBe('[verse] hello world');
} finally {
process.chdir(previousCwd);
rmSync(tempDir, { recursive: true, force: true });
}
});

it('uses lyrics_generation output for --lyrics-optimizer and saves to the requested path', async () => {
const expectedLyrics = '[verse] generated lyrics';
let musicRequestBody: Record<string, unknown> | undefined;

server = createMockServer({
routes: {
async '/v1/lyrics_generation'(req) {
const body = await req.json() as Record<string, unknown>;
expect(body.mode).toBe('write_full_song');
expect(body.prompt).toBe('Upbeat pop about summer');
return jsonResponse({
base_resp: { status_code: 0, status_msg: 'ok' },
lyrics: expectedLyrics,
song_title: 'Summer Song',
});
},
async '/v1/music_generation'(req) {
musicRequestBody = await req.json() as Record<string, unknown>;
return jsonResponse({
base_resp: { status_code: 0, status_msg: 'ok' },
data: {
audio: Buffer.from('test music').toString('hex'),
status: 0,
},
});
},
},
});

const tempDir = mkdtempSync(join(tmpdir(), 'mmx-music-lyrics-optimizer-'));
const audioPath = join(tempDir, 'song.mp3');
const lyricsPath = join(tempDir, 'lyrics.txt');

try {
await generateCommand.execute(
{ ...baseConfig, baseUrl: server.url, quiet: true },
{
...baseFlags,
quiet: true,
prompt: 'Upbeat pop about summer',
lyricsOptimizer: true,
out: audioPath,
lyricsOut: lyricsPath,
},
);
expect(musicRequestBody?.lyrics).toBe(expectedLyrics);
expect(musicRequestBody?.lyrics_optimizer).toBeUndefined();
expect(existsSync(lyricsPath)).toBe(true);
expect(readFileSync(lyricsPath, 'utf-8')).toBe(expectedLyrics);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});

it('still saves lyrics when audio is requested as url output', async () => {
server = createMockServer({
routes: {
'/v1/music_generation': () => jsonResponse({
base_resp: { status_code: 0, status_msg: 'ok' },
data: {
audio_url: 'https://example.com/song.mp3',
status: 0,
},
}),
},
});

const tempDir = mkdtempSync(join(tmpdir(), 'mmx-music-url-output-'));
const lyricsPath = join(tempDir, 'lyrics.txt');

try {
await generateCommand.execute(
{ ...baseConfig, baseUrl: server.url, quiet: true },
{
...baseFlags,
quiet: true,
prompt: 'Folk',
lyrics: '[verse] hello world',
outputFormat: 'url',
lyricsOut: lyricsPath,
},
);
expect(existsSync(lyricsPath)).toBe(true);
expect(readFileSync(lyricsPath, 'utf-8')).toBe('[verse] hello world');
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
});