diff --git a/src/client/endpoints.ts b/src/client/endpoints.ts index b8337a0..058229a 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 musicCoverPreprocessEndpoint(baseUrl: string): string { + return `${baseUrl}/v1/music_cover_preprocess`; +} + export function searchEndpoint(baseUrl: string): string { return `${baseUrl}/v1/coding_plan/search`; } diff --git a/src/commands/music/cover.ts b/src/commands/music/cover.ts index 451d66d..b65f3f5 100644 --- a/src/commands/music/cover.ts +++ b/src/commands/music/cover.ts @@ -3,14 +3,14 @@ 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 { musicCoverPreprocessEndpoint, musicEndpoint } from '../../client/endpoints'; import { formatOutput, detectOutputFormat } from '../../output/formatter'; import { saveAudioOutput } from '../../output/audio'; 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 { CoverPreprocessRequest, CoverPreprocessResponse, MusicRequest, MusicResponse } from '../../types/api'; import { musicCoverModel } from './models'; export default defineCommand({ @@ -23,8 +23,8 @@ export default defineCommand({ { 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)' }, - { flag: '--lyrics ', description: 'Cover lyrics. If omitted, extracted from reference audio via ASR.' }, - { flag: '--lyrics-file ', description: 'Read lyrics from file (use - for stdin)' }, + { flag: '--lyrics ', description: 'Cover lyrics. If provided, CLI auto-runs cover preprocess first. If omitted, lyrics are extracted from reference audio via ASR.' }, + { flag: '--lyrics-file ', description: 'Read lyrics from file (use - for stdin). Triggers cover preprocess automatically.' }, { flag: '--seed ', description: 'Random seed 0–1000000 for reproducible results', type: 'number' }, { flag: '--format ', description: `Audio format: ${formatList(MUSIC_FORMATS)} (default: mp3)` }, { flag: '--sample-rate ', description: 'Sample rate: 16000, 24000, 32000, 44100 (default: 44100)', type: 'number' }, @@ -80,6 +80,10 @@ export default defineCommand({ 'mmx music cover --model music-cover', ); } + const audioSource: Pick = audioUrl + ? { audio_url: audioUrl } + : { audio_base64: readFileSync(audioFile!).toString('base64') }; + const body: MusicRequest = { model, prompt, @@ -93,21 +97,41 @@ export default defineCommand({ }, output_format: 'hex', stream: flags.stream === true, + ...audioSource, }; - if (audioUrl) { - body.audio_url = audioUrl; - } else { - body.audio_base64 = readFileSync(audioFile!).toString('base64'); + const needsPreprocess = Boolean(lyrics?.trim()); + const preprocessBody: CoverPreprocessRequest | undefined = needsPreprocess + ? { + model: 'music-cover', + ...audioSource, + } + : undefined; + + if (needsPreprocess) { + delete body.audio_url; + delete body.audio_base64; + body.cover_feature_id = '__cover_feature_id_from_preprocess__'; } if (config.dryRun) { - console.log(formatOutput({ request: body }, format)); + console.log(formatOutput(needsPreprocess ? { preprocess_request: preprocessBody, request: body } : { request: body }, format)); return; } const url = musicEndpoint(config.baseUrl); + if (needsPreprocess) { + const preprocessUrl = musicCoverPreprocessEndpoint(config.baseUrl); + const preprocessResponse = await requestJson(config, { + url: preprocessUrl, + method: 'POST', + body: preprocessBody, + }); + + body.cover_feature_id = preprocessResponse.cover_feature_id; + } + if (flags.stream) { const res = await request(config, { url, method: 'POST', body, stream: true }); await pipeAudioStream(res); diff --git a/src/types/api.ts b/src/types/api.ts index ac0e998..8d60b2b 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -212,6 +212,7 @@ export interface MusicRequest { lyrics_optimizer?: boolean; audio_url?: string; audio_base64?: string; + cover_feature_id?: string; seed?: number; audio_setting?: { format?: string; @@ -239,6 +240,21 @@ export interface MusicResponse { }; } +export interface CoverPreprocessRequest { + model: 'music-cover'; + audio_url?: string; + audio_base64?: string; +} + +export interface CoverPreprocessResponse { + cover_feature_id: string; + formatted_lyrics?: string; + structure_result?: string; + audio_duration?: number; + trace_id?: string; + base_resp: BaseResp; +} + // ---- Quota ---- export interface QuotaResponse { diff --git a/test/commands/music/cover.test.ts b/test/commands/music/cover.test.ts index d812683..05d8fed 100644 --- a/test/commands/music/cover.test.ts +++ b/test/commands/music/cover.test.ts @@ -105,7 +105,11 @@ describe('music cover command', () => { console.log = origLog; const parsed = JSON.parse(captured); + expect(parsed.preprocess_request.model).toBe('music-cover'); + expect(parsed.preprocess_request.audio_url).toBe('https://example.com/ref.mp3'); expect(parsed.request.lyrics).toBe('New lyrics here'); + expect(parsed.request.cover_feature_id).toBe('__cover_feature_id_from_preprocess__'); + expect(parsed.request.audio_url).toBeUndefined(); }); it('accepts optional --seed', async () => {