From ef1a817a1a86688382fa6490d1e9558d844b8092 Mon Sep 17 00:00:00 2001 From: caozeal <18304010993@163.com> Date: Wed, 8 Jul 2026 10:33:49 +0800 Subject: [PATCH] feat(music): support configurable lyrics output path Co-Authored-By: Codex --- README.md | 2 + src/client/endpoints.ts | 4 + src/commands/music/generate.ts | 89 +++++++++++++++++- src/types/api.ts | 14 +++ src/types/commands.ts | 1 + test/commands/music/generate.test.ts | 135 ++++++++++++++++++++++++++- 6 files changed, 240 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 3da5dc41..82f66b82 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/client/endpoints.ts b/src/client/endpoints.ts index 6d44c701..6659edf5 100644 --- a/src/client/endpoints.ts +++ b/src/client/endpoints.ts @@ -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`; } diff --git a/src/commands/music/generate.ts b/src/commands/music/generate.ts index 5d6c685e..e2a0c64e 100644 --- a/src/commands/music/generate.ts +++ b/src/commands/music/generate.ts @@ -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 (--lyrics | --instrumental | --lyrics-optimizer) [--out ] [flags]', + usage: 'mmx music generate --prompt (--lyrics | --instrumental | --lyrics-optimizer) [--out ] [--lyrics-out ] [flags]', options: [ { flag: '--prompt ', description: 'Music style description (e.g. "cinematic orchestral, building tension"). Max 2000 chars when combined with structured flags.' }, { flag: '--lyrics ', 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 ', description: 'Read lyrics from file (use - for stdin). Same tag rules as --lyrics.' }, + { flag: '--lyrics-out ', description: 'Save the resolved lyrics to a file or directory. Default: current directory as .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 ', description: 'Vocal style, e.g. "warm male baritone", "bright female soprano", "duet with harmonies"' }, @@ -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):', @@ -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(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; } @@ -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)); + } + } }, }); diff --git a/src/types/api.ts b/src/types/api.ts index 2a96ecef..c6a8457e 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -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 { diff --git a/src/types/commands.ts b/src/types/commands.ts index 3efa5238..52cd38f3 100644 --- a/src/types/commands.ts +++ b/src/types/commands.ts @@ -63,6 +63,7 @@ export interface MusicGenerateFlags { prompt?: string; lyrics?: string; lyricsFile?: string; + lyricsOut?: string; format?: string; sampleRate?: number; bitrate?: number; diff --git a/test/commands/music/generate.test.ts b/test/commands/music/generate.test.ts index e5d0b160..b42ff4eb 100644 --- a/test/commands/music/generate.test.ts +++ b/test/commands/music/generate.test.ts @@ -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', @@ -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'); }); @@ -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', () => { @@ -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 | undefined; + + server = createMockServer({ + routes: { + async '/v1/lyrics_generation'(req) { + const body = await req.json() as Record; + 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; + 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 }); + } + }); });