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
4 changes: 2 additions & 2 deletions SDK.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ const music = await sdk.music.generate({
// Instrumental
const instrumental = await sdk.music.generate({
prompt: 'Cinematic orchestral',
instrumental: true,
is_instrumental: true,
});

// Auto-generate lyrics
Expand All @@ -135,7 +135,7 @@ const stream = await sdk.music.generate({
});

for await (const chunk of stream) {
// process audio chunks
// chunk contains decoded audio bytes
}

// Structured prompt
Expand Down
12 changes: 6 additions & 6 deletions src/commands/music/cover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,15 @@ import { pipeAudioStream } from '../../utils/audio-stream';
import type { Config } from '../../config/schema';
import type { GlobalFlags } from '../../types/flags';
import type { CoverPreprocessRequest, CoverPreprocessResponse, MusicRequest, MusicResponse } from '../../types/api';
import { musicCoverModel } from './models';
import { MUSIC_COVER_MODELS, musicCoverModel } from './models';

export default defineCommand({
name: 'music cover',
description: 'Generate a cover version of a song based on reference audio (music-cover)',
description: 'Generate a cover version of a song based on reference audio',
apiDocs: '/docs/api-reference/music-generation',
usage: 'mmx music cover --prompt <text> (--audio <url> | --audio-file <path>) [--lyrics <text>] [--out <path>] [flags]',
options: [
{ flag: '--model <model>', description: 'Model: music-cover (default).' },
{ flag: '--model <model>', description: 'Model: music-cover (default) or music-cover-free.' },
{ flag: '--prompt <text>', description: 'Target cover style, e.g. "Indie folk, acoustic guitar, warm male vocal"' },
{ flag: '--audio <url>', description: 'URL of the reference audio (mp3, wav, flac, etc. — 6s to 6min, max 50MB)' },
{ flag: '--audio-file <path>', description: 'Local reference audio file (auto base64-encoded)' },
Expand All @@ -37,6 +37,7 @@ export default defineCommand({
'mmx music cover --prompt "Indie folk, acoustic guitar, warm male vocal" --audio https://example.com/song.mp3 --out cover.mp3',
'mmx music cover --prompt "Jazz, piano, slow" --audio-file original.mp3 --lyrics-file lyrics.txt --out jazz_cover.mp3',
'mmx music cover --prompt "Pop, upbeat" --audio https://example.com/ref.mp3 --seed 42 --out reproducible.mp3',
'mmx music cover --model music-cover-free --prompt "Jazz, piano, slow" --audio https://example.com/ref.mp3 --out free_cover.mp3',
],
async run(config: Config, flags: GlobalFlags) {
const prompt = flags.prompt as string | undefined;
Expand Down Expand Up @@ -72,10 +73,9 @@ export default defineCommand({
const format = detectOutputFormat(config.output);

const model = (flags.model as string) || musicCoverModel(config);
const VALID_MODELS = ['music-cover'];
if (flags.model && !VALID_MODELS.includes(model)) {
if (flags.model && !MUSIC_COVER_MODELS.includes(model as typeof MUSIC_COVER_MODELS[number])) {
throw new CLIError(
`Invalid model "${model}". Valid models: ${VALID_MODELS.join(', ')}`,
`Invalid model "${model}". Valid models: ${MUSIC_COVER_MODELS.join(', ')}`,
ExitCode.USAGE,
'mmx music cover --model music-cover',
);
Expand Down
19 changes: 13 additions & 6 deletions src/commands/music/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ 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 { musicGenerateModel } from './models';
import { MUSIC_GENERATE_MODELS, musicGenerateModel } from './models';

export default defineCommand({
name: 'music generate',
description: 'Generate a song (music-2.6 / music-2.5+ / music-2.5)',
description: 'Generate a song (music-2.6, including free tier)',
apiDocs: '/docs/api-reference/music-generation',
usage: 'mmx music generate --prompt <text> (--lyrics <text> | --instrumental | --lyrics-optimizer) [--out <path>] [flags]',
options: [
Expand All @@ -36,7 +36,7 @@ export default defineCommand({
{ 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"' },
{ flag: '--extra <text>', description: 'Additional fine-grained requirements not covered above' },
{ flag: '--model <model>', description: 'Model: music-2.6 (default), music-2.5+, or music-2.5.' },
{ flag: '--model <model>', description: 'Model: music-2.6 (default), music-2.6-free, music-2.5+, or music-2.5.' },
{ flag: '--output-format <fmt>', description: 'Return format: hex (default, saved to file) or url (24h expiry, download promptly). When --stream, only hex.' },
{ flag: '--aigc-watermark', description: 'Embed AI-generated content watermark in audio for content provenance' },
{ flag: '--format <fmt>', description: `Audio format: ${formatList(MUSIC_FORMATS)} (default: mp3)` },
Expand Down Expand Up @@ -93,6 +93,14 @@ export default defineCommand({
);
}

if ((isInstrumental || lyricsOptimizer) && !prompt?.trim()) {
throw new CLIError(
'--prompt is required with --instrumental or --lyrics-optimizer.',
ExitCode.USAGE,
'mmx music generate --prompt <text> --instrumental',
);
}

if (!isInstrumental && !lyricsOptimizer && !lyrics?.trim()) {
throw new CLIError(
'Lyrics are required. Add --lyrics or --lyrics-file, or use --instrumental for pure music, or --lyrics-optimizer to auto-generate.',
Expand Down Expand Up @@ -127,10 +135,9 @@ export default defineCommand({
const outPath = (flags.out as string | undefined) ?? `music_${ts}.${ext}`;

const model = (flags.model as string) || musicGenerateModel(config);
const VALID_MODELS = ['music-2.6', 'music-2.5+', 'music-2.5'];
if (flags.model && !VALID_MODELS.includes(model)) {
if (flags.model && !MUSIC_GENERATE_MODELS.includes(model as typeof MUSIC_GENERATE_MODELS[number])) {
throw new CLIError(
`Invalid model "${model}". Valid models: ${VALID_MODELS.join(', ')}`,
`Invalid model "${model}". Valid models: ${MUSIC_GENERATE_MODELS.join(', ')}`,
ExitCode.USAGE,
'mmx music generate --model music-2.6',
);
Expand Down
28 changes: 24 additions & 4 deletions src/commands/music/models.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,33 @@
import type { Config } from '../../config/schema';

export function musicGenerateModel(config: Config): string {
return config.defaultMusicModel ?? 'music-2.6';
export const MUSIC_GENERATE_MODELS = [
'music-2.6',
'music-2.6-free',
'music-2.5+',
'music-2.5',
] as const;

export const MUSIC_COVER_MODELS = ['music-cover', 'music-cover-free'] as const;

function includesModel(models: readonly string[], model: string): boolean {
return models.includes(model);
}

const VALID_COVER_MODELS = new Set(['music-cover']);
export function musicGenerateModel(config: Config): string {
if (
config.defaultMusicModel
&& includesModel(MUSIC_GENERATE_MODELS, config.defaultMusicModel)
) {
return config.defaultMusicModel;
}
return 'music-2.6';
}

export function musicCoverModel(config: Config): string {
if (config.defaultMusicModel && VALID_COVER_MODELS.has(config.defaultMusicModel)) {
if (
config.defaultMusicModel
&& includesModel(MUSIC_COVER_MODELS, config.defaultMusicModel)
) {
return config.defaultMusicModel;
}
return 'music-cover';
Expand Down
80 changes: 57 additions & 23 deletions src/sdk/music/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import { ModelPartial } from "../types";
import { SDKError } from "../../errors/base";
import { ExitCode } from "../../errors/codes";
import { toMerged } from "es-toolkit/object";
import { musicGenerateModel } from "../../commands/music/models";
import { MUSIC_GENERATE_MODELS, musicGenerateModel } from "../../commands/music/models";
import { decodeAudioStream } from "../../utils/audio-stream";

function hexToBuffer(hex: string): Buffer {
if (!/^[0-9a-fA-F]*$/.test(hex)) {
Expand Down Expand Up @@ -64,20 +65,11 @@ export class MusicSDK extends Client {
stream: true,
});

const reader = res.body?.getReader();
if (!reader) {
if (!res.body) {
throw new SDKError('No response body', ExitCode.GENERAL);
};

try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
yield value;
}
} finally {
reader.releaseLock();
}

yield* decodeAudioStream(res);
}

async generate(request: ModelPartial<MusicGenerateRequest> & { stream: true }): Promise<AsyncGenerator<Uint8Array<ArrayBuffer>>>;
Expand Down Expand Up @@ -140,16 +132,15 @@ export class MusicSDK extends Client {
if (request.bpm) structuredParts.push(`BPM: ${request.bpm as number}`);
if (request.key) structuredParts.push(`Key: ${request.key as string}`);
if (request.avoid) structuredParts.push(`Avoid: ${request.avoid as string}`);
if (request.useCase) structuredParts.push(`Use case: ${request.useCase as string}`);
const useCase = request.useCase || request.use_case;
if (useCase) structuredParts.push(`Use case: ${useCase}`);
if (request.structure) structuredParts.push(`Structure: ${request.structure as string}`);
if (request.references) structuredParts.push(`References: ${request.references as string}`);
if (request.extra) structuredParts.push(`Extra: ${request.extra as string}`);

let lyrics = request.lyrics;
let prompt = request.prompt;

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

Expand All @@ -161,9 +152,43 @@ export class MusicSDK extends Client {
}

private validateParams(params: ModelPartial<MusicGenerateRequest>) {
const instrumental = params.instrumental;
const apiParams = { ...params };
const sdkOnlyFields: Array<keyof MusicGenerateRequest> = [
'instrumental',
'vocals',
'genre',
'mood',
'instruments',
'tempo',
'bpm',
'key',
'avoid',
'use_case',
'useCase',
'structure',
'references',
'extra',
];
for (const field of sdkOnlyFields) delete apiParams[field];
if (
instrumental !== undefined
&& params.is_instrumental !== undefined
&& instrumental !== params.is_instrumental
) {
throw new SDKError(
'instrumental and is_instrumental must not conflict',
ExitCode.USAGE,
);
}

const normalized: ModelPartial<MusicGenerateRequest> = {
...apiParams,
is_instrumental: params.is_instrumental ?? instrumental,
};
const {
model, output_format, stream, prompt, lyrics, is_instrumental, lyrics_optimizer,
} = params;
} = normalized;
if (is_instrumental && lyrics) {
throw new SDKError('Cannot use is_instrumental with lyrics', ExitCode.USAGE);
}
Expand All @@ -176,14 +201,20 @@ export class MusicSDK extends Client {
throw new SDKError('At least one of prompt or lyrics or is_instrumental or lyrics_optimizer is required', ExitCode.USAGE);
}

if ((is_instrumental || lyrics_optimizer) && !prompt?.trim()) {
throw new SDKError(
'prompt is required with is_instrumental or lyrics_optimizer',
ExitCode.USAGE,
);
}

if (!is_instrumental && !lyrics_optimizer && !lyrics?.trim()) {
throw new SDKError('lyrics is required', ExitCode.USAGE);
}

const VALID_MODELS = ['music-2.6', 'music-2.5+', 'music-2.5'];
if (model && !VALID_MODELS.includes(model)) {
if (model && !MUSIC_GENERATE_MODELS.includes(model as typeof MUSIC_GENERATE_MODELS[number])) {
throw new SDKError(
`Invalid model: ${model}. Valid models are ${VALID_MODELS.join(', ')}.`,
`Invalid model: ${model}. Valid models are ${MUSIC_GENERATE_MODELS.join(', ')}.`,
ExitCode.USAGE,
);
}
Expand All @@ -202,7 +233,10 @@ export class MusicSDK extends Client {
);
}

const targetPrompt = this.buildPrompt(params);
const targetPrompt = this.buildPrompt({
...params,
is_instrumental,
});

return toMerged({
model: musicGenerateModel(this.config),
Expand All @@ -213,7 +247,7 @@ export class MusicSDK extends Client {
},
output_format: 'hex',
}, {
...params,
...normalized,
prompt: targetPrompt,
});
}
Expand Down
21 changes: 14 additions & 7 deletions src/utils/audio-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,9 @@ interface SseAudioPayload {
data?: { audio?: string; status?: number };
}

export async function pipeAudioStream(response: Response): Promise<void> {
process.stdout.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EPIPE') process.exit(0);
throw err;
});

export async function* decodeAudioStream(
response: Response,
): AsyncGenerator<Uint8Array<ArrayBuffer>> {
for await (const event of parseSSE(response)) {
if (!event.data || event.data === '[DONE]') break;

Expand All @@ -21,7 +18,17 @@ export async function pipeAudioStream(response: Response): Promise<void> {
const hex = parsed.data?.audio;
if (!hex) continue;

const chunk = Buffer.from(hex, 'hex');
yield Uint8Array.from(Buffer.from(hex, 'hex'));
}
}

export async function pipeAudioStream(response: Response): Promise<void> {
process.stdout.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EPIPE') process.exit(0);
throw err;
});

for await (const chunk of decodeAudioStream(response)) {
if (!process.stdout.write(chunk)) {
await new Promise<void>(r => process.stdout.once('drain', r));
}
Expand Down
23 changes: 23 additions & 0 deletions test/commands/music/cover.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,29 @@ describe('music cover command', () => {
).rejects.toThrow('Invalid model');
});

it('accepts the official music-cover-free model', async () => {
let captured = '';
const origLog = console.log;
console.log = (msg: string) => { captured += msg; };

try {
await coverCommand.execute(
{ ...baseConfig, dryRun: true, output: 'json' as const },
{
...baseFlags,
dryRun: true,
prompt: 'Folk cover',
audio: 'https://example.com/ref.mp3',
model: 'music-cover-free',
},
);
} finally {
console.log = origLog;
}

expect(JSON.parse(captured).request.model).toBe('music-cover-free');
});

it('rejects invalid audio format', async () => {
await expect(
coverCommand.execute(
Expand Down
32 changes: 32 additions & 0 deletions test/commands/music/generate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,38 @@ describe('music generate command', () => {
expect(parsed.request.model).toBe('music-2.5');
});

it('accepts the official music-2.6-free model', async () => {
let captured = '';
const origLog = console.log;
console.log = (msg: string) => { captured += msg; };

try {
await generateCommand.execute(
{ ...baseConfig, dryRun: true, output: 'json' as const },
{
...baseFlags,
dryRun: true,
prompt: 'Folk',
lyrics: 'la la',
model: 'music-2.6-free',
},
);
} finally {
console.log = origLog;
}

expect(JSON.parse(captured).request.model).toBe('music-2.6-free');
});

it('requires prompt for instrumental generation', async () => {
await expect(
generateCommand.execute(
{ ...baseConfig, dryRun: true },
{ ...baseFlags, dryRun: true, instrumental: true },
),
).rejects.toThrow('--prompt is required with --instrumental');
});

it('rejects invalid audio format', async () => {
await expect(
generateCommand.execute(
Expand Down
Loading
Loading