Skip to content

Commit ef1a817

Browse files
committed
feat(music): support configurable lyrics output path
Co-Authored-By: Codex
1 parent 5f13ef5 commit ef1a817

6 files changed

Lines changed: 240 additions & 5 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,8 @@ mmx speech voices
108108
mmx music generate --prompt "Upbeat pop" --lyrics "[verse] La da dee, sunny day" --out song.mp3
109109
# Auto-generate lyrics from prompt
110110
mmx music generate --prompt "Indie folk, melancholic, rainy night" --lyrics-optimizer --out song.mp3
111+
# Save lyrics to a custom path (defaults to current directory)
112+
mmx music generate --prompt "Indie folk, melancholic, rainy night" --lyrics-optimizer --out song.mp3 --lyrics-out ./lyrics/
111113
# Instrumental (no vocals)
112114
mmx music generate --prompt "Cinematic orchestral" --instrumental --out bgm.mp3
113115
# Cover — generate a cover version from a reference audio file

src/client/endpoints.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ export function musicEndpoint(baseUrl: string): string {
3030
return `${baseUrl}/v1/music_generation`;
3131
}
3232

33+
export function lyricsGenerationEndpoint(baseUrl: string): string {
34+
return `${baseUrl}/v1/lyrics_generation`;
35+
}
36+
3337
export function searchEndpoint(baseUrl: string): string {
3438
return `${baseUrl}/v1/coding_plan/search`;
3539
}

src/commands/music/generate.ts

Lines changed: 85 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,59 @@ import { defineCommand } from '../../command';
22
import { CLIError } from '../../errors/base';
33
import { ExitCode } from '../../errors/codes';
44
import { request, requestJson } from '../../client/http';
5-
import { musicEndpoint } from '../../client/endpoints';
6-
import { detectOutputFormat, dryRun } from '../../output/formatter';
5+
import { existsSync, mkdirSync, statSync, writeFileSync } from 'node:fs';
6+
import { basename, dirname, extname, join, resolve } from 'node:path';
7+
import { lyricsGenerationEndpoint, musicEndpoint } from '../../client/endpoints';
8+
import { detectOutputFormat, dryRun, formatOutput } from '../../output/formatter';
79
import { saveAudioOutput } from '../../output/audio';
810
import { readTextFromPathOrStdin } from '../../utils/fs';
911
import { MUSIC_FORMATS, formatList, validateAudioFormat } from '../../utils/audio-formats';
1012
import { pipeAudioStream } from '../../utils/audio-stream';
1113
import type { Config } from '../../config/schema';
1214
import type { GlobalFlags } from '../../types/flags';
13-
import type { MusicRequest, MusicResponse } from '../../types/api';
15+
import type { LyricsGenerationRequest, LyricsGenerationResponse, MusicRequest, MusicResponse } from '../../types/api';
1416
import { musicGenerateModel } from './models';
1517

18+
function defaultLyricsFilename(audioOutPath: string): string {
19+
return `${basename(audioOutPath, extname(audioOutPath))}.lyrics.txt`;
20+
}
21+
22+
function resolveLyricsOutputPath(lyricsOut: string | undefined, audioOutPath: string): string {
23+
const filename = defaultLyricsFilename(audioOutPath);
24+
25+
if (!lyricsOut) {
26+
return resolve(filename);
27+
}
28+
29+
const dest = resolve(lyricsOut);
30+
const treatAsDirectory =
31+
lyricsOut.endsWith('/') ||
32+
lyricsOut.endsWith('\\') ||
33+
(existsSync(dest) && statSync(dest).isDirectory());
34+
35+
return treatAsDirectory ? join(dest, filename) : dest;
36+
}
37+
38+
function saveLyricsOutput(lyrics: string, lyricsOut: string | undefined, audioOutPath: string): string {
39+
const dest = resolveLyricsOutputPath(lyricsOut, audioOutPath);
40+
const dir = dirname(dest);
41+
if (!existsSync(dir)) {
42+
mkdirSync(dir, { recursive: true });
43+
}
44+
writeFileSync(dest, lyrics, 'utf-8');
45+
return dest;
46+
}
47+
1648
export default defineCommand({
1749
name: 'music generate',
1850
description: 'Generate a song (music-2.6 / music-2.5+ / music-2.5)',
1951
apiDocs: '/docs/api-reference/music-generation',
20-
usage: 'mmx music generate --prompt <text> (--lyrics <text> | --instrumental | --lyrics-optimizer) [--out <path>] [flags]',
52+
usage: 'mmx music generate --prompt <text> (--lyrics <text> | --instrumental | --lyrics-optimizer) [--out <path>] [--lyrics-out <path>] [flags]',
2153
options: [
2254
{ flag: '--prompt <text>', description: 'Music style description (e.g. "cinematic orchestral, building tension"). Max 2000 chars when combined with structured flags.' },
2355
{ 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.' },
2456
{ flag: '--lyrics-file <path>', description: 'Read lyrics from file (use - for stdin). Same tag rules as --lyrics.' },
57+
{ flag: '--lyrics-out <path>', description: 'Save the resolved lyrics to a file or directory. Default: current directory as <audio-basename>.lyrics.txt.' },
2558
{ flag: '--lyrics-optimizer', description: 'Auto-generate lyrics from prompt (cannot be used with --lyrics or --instrumental)' },
2659
{ 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.' },
2760
{ flag: '--vocals <text>', description: 'Vocal style, e.g. "warm male baritone", "bright female soprano", "duet with harmonies"' },
@@ -50,6 +83,7 @@ export default defineCommand({
5083
'mmx music generate --prompt "Indie folk, melancholic" --lyrics-file song.txt --out my_song.mp3',
5184
'# Auto-generate lyrics from prompt:',
5285
'mmx music generate --prompt "Upbeat pop about summer" --lyrics-optimizer --out summer.mp3',
86+
'mmx music generate --prompt "Upbeat pop about summer" --lyrics-optimizer --out summer.mp3 --lyrics-out ./lyrics/',
5387
'# Instrumental:',
5488
'mmx music generate --prompt "Cinematic orchestral, building tension" --instrumental --out bgm.mp3',
5589
'# URL output (24h expiry — download promptly):',
@@ -171,10 +205,41 @@ export default defineCommand({
171205

172206
const format = detectOutputFormat(config.output);
173207
const url = musicEndpoint(config.baseUrl);
208+
let resolvedLyrics = lyrics?.trim() ? lyrics : undefined;
209+
210+
if (lyricsOptimizer) {
211+
const lyricsResponse = await requestJson<LyricsGenerationResponse>(config, {
212+
url: lyricsGenerationEndpoint(config.baseUrl),
213+
method: 'POST',
214+
body: {
215+
mode: 'write_full_song',
216+
prompt,
217+
} satisfies LyricsGenerationRequest,
218+
});
219+
220+
if (!lyricsResponse.lyrics?.trim()) {
221+
throw new CLIError(
222+
'Lyrics optimizer did not return lyrics.',
223+
ExitCode.GENERAL,
224+
);
225+
}
226+
227+
resolvedLyrics = lyricsResponse.lyrics;
228+
body.lyrics = resolvedLyrics;
229+
body.lyrics_optimizer = undefined;
230+
}
174231

175232
if (flags.stream) {
176233
const res = await request(config, { url, method: 'POST', body, stream: true });
177234
await pipeAudioStream(res);
235+
if (resolvedLyrics) {
236+
const lyricsPath = saveLyricsOutput(resolvedLyrics, flags.lyricsOut as string | undefined, outPath);
237+
if (config.quiet) {
238+
console.log(lyricsPath);
239+
} else {
240+
console.log(formatOutput({ lyrics: lyricsPath }, format));
241+
}
242+
}
178243
return;
179244
}
180245

@@ -194,8 +259,24 @@ export default defineCommand({
194259
ExitCode.GENERAL,
195260
);
196261
}
262+
if (resolvedLyrics) {
263+
const lyricsPath = saveLyricsOutput(resolvedLyrics, flags.lyricsOut as string | undefined, outPath);
264+
if (config.quiet) {
265+
console.log(lyricsPath);
266+
} else {
267+
console.log(formatOutput({ lyrics: lyricsPath }, format));
268+
}
269+
}
197270
return;
198271
}
199272
saveAudioOutput(response, outPath, format, config.quiet);
273+
if (resolvedLyrics) {
274+
const lyricsPath = saveLyricsOutput(resolvedLyrics, flags.lyricsOut as string | undefined, outPath);
275+
if (config.quiet) {
276+
console.log(lyricsPath);
277+
} else {
278+
console.log(formatOutput({ lyrics: lyricsPath }, format));
279+
}
280+
}
200281
},
201282
});

src/types/api.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,20 @@ export interface MusicResponse {
239239
};
240240
}
241241

242+
export interface LyricsGenerationRequest {
243+
mode: 'write_full_song' | 'edit';
244+
prompt?: string;
245+
lyrics?: string;
246+
title?: string;
247+
}
248+
249+
export interface LyricsGenerationResponse {
250+
song_title?: string;
251+
style_tags?: string;
252+
lyrics: string;
253+
base_resp: BaseResp;
254+
}
255+
242256
// ---- Quota ----
243257

244258
export interface QuotaResponse {

src/types/commands.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export interface MusicGenerateFlags {
6363
prompt?: string;
6464
lyrics?: string;
6565
lyricsFile?: string;
66+
lyricsOut?: string;
6667
format?: string;
6768
sampleRate?: number;
6869
bitrate?: number;

test/commands/music/generate.test.ts

Lines changed: 134 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1-
import { describe, it, expect } from 'bun:test';
1+
import { afterEach, describe, expect, it } from 'bun:test';
2+
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
3+
import { join } from 'node:path';
4+
import { tmpdir } from 'node:os';
25
import { default as generateCommand } from '../../../src/commands/music/generate';
6+
import { createMockServer, jsonResponse, type MockServer } from '../../helpers/mock-server';
37

48
const baseConfig = {
59
apiKey: 'test-key',
@@ -28,6 +32,13 @@ const baseFlags = {
2832
};
2933

3034
describe('music generate command', () => {
35+
let server: MockServer | undefined;
36+
37+
afterEach(() => {
38+
server?.close();
39+
server = undefined;
40+
});
41+
3142
it('has correct name', () => {
3243
expect(generateCommand.name).toBe('music generate');
3344
});
@@ -87,6 +98,7 @@ describe('music generate command', () => {
8798
expect(optionFlags.some((f) => f.startsWith('--extra'))).toBe(true);
8899
expect(optionFlags.some((f) => f.startsWith('--instrumental'))).toBe(true);
89100
expect(optionFlags.some((f) => f.startsWith('--aigc-watermark'))).toBe(true);
101+
expect(optionFlags.some((f) => f.startsWith('--lyrics-out'))).toBe(true);
90102
});
91103

92104
it('examples include vocal, instrumental, and lyrics-optimizer usage', () => {
@@ -208,4 +220,125 @@ describe('music generate command', () => {
208220
}
209221
},
210222
);
223+
224+
it('saves provided lyrics to the current directory by default', async () => {
225+
server = createMockServer({
226+
routes: {
227+
'/v1/music_generation': () => jsonResponse({
228+
base_resp: { status_code: 0, status_msg: 'ok' },
229+
data: {
230+
audio: Buffer.from('test music').toString('hex'),
231+
status: 0,
232+
},
233+
}),
234+
},
235+
});
236+
237+
const tempDir = mkdtempSync(join(tmpdir(), 'mmx-music-generate-'));
238+
const previousCwd = process.cwd();
239+
const audioPath = join(tempDir, 'song.mp3');
240+
const expectedLyricsPath = join(tempDir, 'song.lyrics.txt');
241+
242+
try {
243+
process.chdir(tempDir);
244+
await generateCommand.execute(
245+
{ ...baseConfig, baseUrl: server.url, quiet: true },
246+
{ ...baseFlags, quiet: true, prompt: 'Folk', lyrics: '[verse] hello world', out: audioPath },
247+
);
248+
expect(existsSync(expectedLyricsPath)).toBe(true);
249+
expect(readFileSync(expectedLyricsPath, 'utf-8')).toBe('[verse] hello world');
250+
} finally {
251+
process.chdir(previousCwd);
252+
rmSync(tempDir, { recursive: true, force: true });
253+
}
254+
});
255+
256+
it('uses lyrics_generation output for --lyrics-optimizer and saves to the requested path', async () => {
257+
const expectedLyrics = '[verse] generated lyrics';
258+
let musicRequestBody: Record<string, unknown> | undefined;
259+
260+
server = createMockServer({
261+
routes: {
262+
async '/v1/lyrics_generation'(req) {
263+
const body = await req.json() as Record<string, unknown>;
264+
expect(body.mode).toBe('write_full_song');
265+
expect(body.prompt).toBe('Upbeat pop about summer');
266+
return jsonResponse({
267+
base_resp: { status_code: 0, status_msg: 'ok' },
268+
lyrics: expectedLyrics,
269+
song_title: 'Summer Song',
270+
});
271+
},
272+
async '/v1/music_generation'(req) {
273+
musicRequestBody = await req.json() as Record<string, unknown>;
274+
return jsonResponse({
275+
base_resp: { status_code: 0, status_msg: 'ok' },
276+
data: {
277+
audio: Buffer.from('test music').toString('hex'),
278+
status: 0,
279+
},
280+
});
281+
},
282+
},
283+
});
284+
285+
const tempDir = mkdtempSync(join(tmpdir(), 'mmx-music-lyrics-optimizer-'));
286+
const audioPath = join(tempDir, 'song.mp3');
287+
const lyricsPath = join(tempDir, 'lyrics.txt');
288+
289+
try {
290+
await generateCommand.execute(
291+
{ ...baseConfig, baseUrl: server.url, quiet: true },
292+
{
293+
...baseFlags,
294+
quiet: true,
295+
prompt: 'Upbeat pop about summer',
296+
lyricsOptimizer: true,
297+
out: audioPath,
298+
lyricsOut: lyricsPath,
299+
},
300+
);
301+
expect(musicRequestBody?.lyrics).toBe(expectedLyrics);
302+
expect(musicRequestBody?.lyrics_optimizer).toBeUndefined();
303+
expect(existsSync(lyricsPath)).toBe(true);
304+
expect(readFileSync(lyricsPath, 'utf-8')).toBe(expectedLyrics);
305+
} finally {
306+
rmSync(tempDir, { recursive: true, force: true });
307+
}
308+
});
309+
310+
it('still saves lyrics when audio is requested as url output', async () => {
311+
server = createMockServer({
312+
routes: {
313+
'/v1/music_generation': () => jsonResponse({
314+
base_resp: { status_code: 0, status_msg: 'ok' },
315+
data: {
316+
audio_url: 'https://example.com/song.mp3',
317+
status: 0,
318+
},
319+
}),
320+
},
321+
});
322+
323+
const tempDir = mkdtempSync(join(tmpdir(), 'mmx-music-url-output-'));
324+
const lyricsPath = join(tempDir, 'lyrics.txt');
325+
326+
try {
327+
await generateCommand.execute(
328+
{ ...baseConfig, baseUrl: server.url, quiet: true },
329+
{
330+
...baseFlags,
331+
quiet: true,
332+
prompt: 'Folk',
333+
lyrics: '[verse] hello world',
334+
outputFormat: 'url',
335+
lyricsOut: lyricsPath,
336+
},
337+
);
338+
expect(existsSync(lyricsPath)).toBe(true);
339+
expect(readFileSync(lyricsPath, 'utf-8')).toBe('[verse] hello world');
340+
} finally {
341+
rmSync(tempDir, { recursive: true, force: true });
342+
}
343+
});
211344
});

0 commit comments

Comments
 (0)