Skip to content

Commit facbb95

Browse files
committed
fix: align music clients with current API
1 parent eca6acf commit facbb95

10 files changed

Lines changed: 254 additions & 64 deletions

File tree

SDK.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ const music = await sdk.music.generate({
118118
// Instrumental
119119
const instrumental = await sdk.music.generate({
120120
prompt: 'Cinematic orchestral',
121-
instrumental: true,
121+
is_instrumental: true,
122122
});
123123

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

137137
for await (const chunk of stream) {
138-
// process audio chunks
138+
// chunk contains decoded audio bytes
139139
}
140140

141141
// Structured prompt

src/commands/music/cover.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,15 @@ import { pipeAudioStream } from '../../utils/audio-stream';
1111
import type { Config } from '../../config/schema';
1212
import type { GlobalFlags } from '../../types/flags';
1313
import type { CoverPreprocessRequest, CoverPreprocessResponse, MusicRequest, MusicResponse } from '../../types/api';
14-
import { musicCoverModel } from './models';
14+
import { MUSIC_COVER_MODELS, musicCoverModel } from './models';
1515

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

7475
const model = (flags.model as string) || musicCoverModel(config);
75-
const VALID_MODELS = ['music-cover'];
76-
if (flags.model && !VALID_MODELS.includes(model)) {
76+
if (flags.model && !MUSIC_COVER_MODELS.includes(model as typeof MUSIC_COVER_MODELS[number])) {
7777
throw new CLIError(
78-
`Invalid model "${model}". Valid models: ${VALID_MODELS.join(', ')}`,
78+
`Invalid model "${model}". Valid models: ${MUSIC_COVER_MODELS.join(', ')}`,
7979
ExitCode.USAGE,
8080
'mmx music cover --model music-cover',
8181
);

src/commands/music/generate.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,11 @@ import { pipeAudioStream } from '../../utils/audio-stream';
1111
import type { Config } from '../../config/schema';
1212
import type { GlobalFlags } from '../../types/flags';
1313
import type { MusicRequest, MusicResponse } from '../../types/api';
14-
import { musicGenerateModel } from './models';
14+
import { MUSIC_GENERATE_MODELS, musicGenerateModel } from './models';
1515

1616
export default defineCommand({
1717
name: 'music generate',
18-
description: 'Generate a song (music-2.6 / music-2.5+ / music-2.5)',
18+
description: 'Generate a song (music-2.6, including free tier)',
1919
apiDocs: '/docs/api-reference/music-generation',
2020
usage: 'mmx music generate --prompt <text> (--lyrics <text> | --instrumental | --lyrics-optimizer) [--out <path>] [flags]',
2121
options: [
@@ -36,7 +36,7 @@ export default defineCommand({
3636
{ flag: '--structure <text>', description: 'Song structure, e.g. "verse-chorus-verse-bridge-chorus"' },
3737
{ flag: '--references <text>', description: 'Reference tracks or artists, e.g. "similar to Ed Sheeran"' },
3838
{ flag: '--extra <text>', description: 'Additional fine-grained requirements not covered above' },
39-
{ flag: '--model <model>', description: 'Model: music-2.6 (default), music-2.5+, or music-2.5.' },
39+
{ flag: '--model <model>', description: 'Model: music-2.6 (default), music-2.6-free, music-2.5+, or music-2.5.' },
4040
{ flag: '--output-format <fmt>', description: 'Return format: hex (default, saved to file) or url (24h expiry, download promptly). When --stream, only hex.' },
4141
{ flag: '--aigc-watermark', description: 'Embed AI-generated content watermark in audio for content provenance' },
4242
{ flag: '--format <fmt>', description: `Audio format: ${formatList(MUSIC_FORMATS)} (default: mp3)` },
@@ -93,6 +93,14 @@ export default defineCommand({
9393
);
9494
}
9595

96+
if ((isInstrumental || lyricsOptimizer) && !prompt?.trim()) {
97+
throw new CLIError(
98+
'--prompt is required with --instrumental or --lyrics-optimizer.',
99+
ExitCode.USAGE,
100+
'mmx music generate --prompt <text> --instrumental',
101+
);
102+
}
103+
96104
if (!isInstrumental && !lyricsOptimizer && !lyrics?.trim()) {
97105
throw new CLIError(
98106
'Lyrics are required. Add --lyrics or --lyrics-file, or use --instrumental for pure music, or --lyrics-optimizer to auto-generate.',
@@ -127,10 +135,9 @@ export default defineCommand({
127135
const outPath = (flags.out as string | undefined) ?? `music_${ts}.${ext}`;
128136

129137
const model = (flags.model as string) || musicGenerateModel(config);
130-
const VALID_MODELS = ['music-2.6', 'music-2.5+', 'music-2.5'];
131-
if (flags.model && !VALID_MODELS.includes(model)) {
138+
if (flags.model && !MUSIC_GENERATE_MODELS.includes(model as typeof MUSIC_GENERATE_MODELS[number])) {
132139
throw new CLIError(
133-
`Invalid model "${model}". Valid models: ${VALID_MODELS.join(', ')}`,
140+
`Invalid model "${model}". Valid models: ${MUSIC_GENERATE_MODELS.join(', ')}`,
134141
ExitCode.USAGE,
135142
'mmx music generate --model music-2.6',
136143
);

src/commands/music/models.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,33 @@
11
import type { Config } from '../../config/schema';
22

3-
export function musicGenerateModel(config: Config): string {
4-
return config.defaultMusicModel ?? 'music-2.6';
3+
export const MUSIC_GENERATE_MODELS = [
4+
'music-2.6',
5+
'music-2.6-free',
6+
'music-2.5+',
7+
'music-2.5',
8+
] as const;
9+
10+
export const MUSIC_COVER_MODELS = ['music-cover', 'music-cover-free'] as const;
11+
12+
function includesModel(models: readonly string[], model: string): boolean {
13+
return models.includes(model);
514
}
615

7-
const VALID_COVER_MODELS = new Set(['music-cover']);
16+
export function musicGenerateModel(config: Config): string {
17+
if (
18+
config.defaultMusicModel
19+
&& includesModel(MUSIC_GENERATE_MODELS, config.defaultMusicModel)
20+
) {
21+
return config.defaultMusicModel;
22+
}
23+
return 'music-2.6';
24+
}
825

926
export function musicCoverModel(config: Config): string {
10-
if (config.defaultMusicModel && VALID_COVER_MODELS.has(config.defaultMusicModel)) {
27+
if (
28+
config.defaultMusicModel
29+
&& includesModel(MUSIC_COVER_MODELS, config.defaultMusicModel)
30+
) {
1131
return config.defaultMusicModel;
1232
}
1333
return 'music-cover';

src/sdk/music/index.ts

Lines changed: 57 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ import { ModelPartial } from "../types";
77
import { SDKError } from "../../errors/base";
88
import { ExitCode } from "../../errors/codes";
99
import { toMerged } from "es-toolkit/object";
10-
import { musicGenerateModel } from "../../commands/music/models";
10+
import { MUSIC_GENERATE_MODELS, musicGenerateModel } from "../../commands/music/models";
11+
import { decodeAudioStream } from "../../utils/audio-stream";
1112

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

67-
const reader = res.body?.getReader();
68-
if (!reader) {
68+
if (!res.body) {
6969
throw new SDKError('No response body', ExitCode.GENERAL);
70-
};
71-
72-
try {
73-
while (true) {
74-
const { done, value } = await reader.read();
75-
if (done) break;
76-
yield value;
77-
}
78-
} finally {
79-
reader.releaseLock();
8070
}
71+
72+
yield* decodeAudioStream(res);
8173
}
8274

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

148-
let lyrics = request.lyrics;
149141
let prompt = request.prompt;
150142

151-
if (request.instrumental || !lyrics || lyrics === '无歌词' || lyrics === 'no lyrics') {
152-
lyrics = '[intro] [outro]';
143+
if (request.is_instrumental || request.instrumental) {
153144
structuredParts.push('Style: instrumental, no vocals, pure music');
154145
}
155146

@@ -161,9 +152,43 @@ export class MusicSDK extends Client {
161152
}
162153

163154
private validateParams(params: ModelPartial<MusicGenerateRequest>) {
155+
const instrumental = params.instrumental;
156+
const apiParams = { ...params };
157+
const sdkOnlyFields: Array<keyof MusicGenerateRequest> = [
158+
'instrumental',
159+
'vocals',
160+
'genre',
161+
'mood',
162+
'instruments',
163+
'tempo',
164+
'bpm',
165+
'key',
166+
'avoid',
167+
'use_case',
168+
'useCase',
169+
'structure',
170+
'references',
171+
'extra',
172+
];
173+
for (const field of sdkOnlyFields) delete apiParams[field];
174+
if (
175+
instrumental !== undefined
176+
&& params.is_instrumental !== undefined
177+
&& instrumental !== params.is_instrumental
178+
) {
179+
throw new SDKError(
180+
'instrumental and is_instrumental must not conflict',
181+
ExitCode.USAGE,
182+
);
183+
}
184+
185+
const normalized: ModelPartial<MusicGenerateRequest> = {
186+
...apiParams,
187+
is_instrumental: params.is_instrumental ?? instrumental,
188+
};
164189
const {
165190
model, output_format, stream, prompt, lyrics, is_instrumental, lyrics_optimizer,
166-
} = params;
191+
} = normalized;
167192
if (is_instrumental && lyrics) {
168193
throw new SDKError('Cannot use is_instrumental with lyrics', ExitCode.USAGE);
169194
}
@@ -176,14 +201,20 @@ export class MusicSDK extends Client {
176201
throw new SDKError('At least one of prompt or lyrics or is_instrumental or lyrics_optimizer is required', ExitCode.USAGE);
177202
}
178203

204+
if ((is_instrumental || lyrics_optimizer) && !prompt?.trim()) {
205+
throw new SDKError(
206+
'prompt is required with is_instrumental or lyrics_optimizer',
207+
ExitCode.USAGE,
208+
);
209+
}
210+
179211
if (!is_instrumental && !lyrics_optimizer && !lyrics?.trim()) {
180212
throw new SDKError('lyrics is required', ExitCode.USAGE);
181213
}
182214

183-
const VALID_MODELS = ['music-2.6', 'music-2.5+', 'music-2.5'];
184-
if (model && !VALID_MODELS.includes(model)) {
215+
if (model && !MUSIC_GENERATE_MODELS.includes(model as typeof MUSIC_GENERATE_MODELS[number])) {
185216
throw new SDKError(
186-
`Invalid model: ${model}. Valid models are ${VALID_MODELS.join(', ')}.`,
217+
`Invalid model: ${model}. Valid models are ${MUSIC_GENERATE_MODELS.join(', ')}.`,
187218
ExitCode.USAGE,
188219
);
189220
}
@@ -202,7 +233,10 @@ export class MusicSDK extends Client {
202233
);
203234
}
204235

205-
const targetPrompt = this.buildPrompt(params);
236+
const targetPrompt = this.buildPrompt({
237+
...params,
238+
is_instrumental,
239+
});
206240

207241
return toMerged({
208242
model: musicGenerateModel(this.config),
@@ -213,7 +247,7 @@ export class MusicSDK extends Client {
213247
},
214248
output_format: 'hex',
215249
}, {
216-
...params,
250+
...normalized,
217251
prompt: targetPrompt,
218252
});
219253
}

src/utils/audio-stream.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,9 @@ interface SseAudioPayload {
44
data?: { audio?: string; status?: number };
55
}
66

7-
export async function pipeAudioStream(response: Response): Promise<void> {
8-
process.stdout.on('error', (err: NodeJS.ErrnoException) => {
9-
if (err.code === 'EPIPE') process.exit(0);
10-
throw err;
11-
});
12-
7+
export async function* decodeAudioStream(
8+
response: Response,
9+
): AsyncGenerator<Uint8Array<ArrayBuffer>> {
1310
for await (const event of parseSSE(response)) {
1411
if (!event.data || event.data === '[DONE]') break;
1512

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

24-
const chunk = Buffer.from(hex, 'hex');
21+
yield Uint8Array.from(Buffer.from(hex, 'hex'));
22+
}
23+
}
24+
25+
export async function pipeAudioStream(response: Response): Promise<void> {
26+
process.stdout.on('error', (err: NodeJS.ErrnoException) => {
27+
if (err.code === 'EPIPE') process.exit(0);
28+
throw err;
29+
});
30+
31+
for await (const chunk of decodeAudioStream(response)) {
2532
if (!process.stdout.write(chunk)) {
2633
await new Promise<void>(r => process.stdout.once('drain', r));
2734
}

test/commands/music/cover.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,29 @@ describe('music cover command', () => {
151151
).rejects.toThrow('Invalid model');
152152
});
153153

154+
it('accepts the official music-cover-free model', async () => {
155+
let captured = '';
156+
const origLog = console.log;
157+
console.log = (msg: string) => { captured += msg; };
158+
159+
try {
160+
await coverCommand.execute(
161+
{ ...baseConfig, dryRun: true, output: 'json' as const },
162+
{
163+
...baseFlags,
164+
dryRun: true,
165+
prompt: 'Folk cover',
166+
audio: 'https://example.com/ref.mp3',
167+
model: 'music-cover-free',
168+
},
169+
);
170+
} finally {
171+
console.log = origLog;
172+
}
173+
174+
expect(JSON.parse(captured).request.model).toBe('music-cover-free');
175+
});
176+
154177
it('rejects invalid audio format', async () => {
155178
await expect(
156179
coverCommand.execute(

test/commands/music/generate.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,38 @@ describe('music generate command', () => {
181181
expect(parsed.request.model).toBe('music-2.5');
182182
});
183183

184+
it('accepts the official music-2.6-free model', async () => {
185+
let captured = '';
186+
const origLog = console.log;
187+
console.log = (msg: string) => { captured += msg; };
188+
189+
try {
190+
await generateCommand.execute(
191+
{ ...baseConfig, dryRun: true, output: 'json' as const },
192+
{
193+
...baseFlags,
194+
dryRun: true,
195+
prompt: 'Folk',
196+
lyrics: 'la la',
197+
model: 'music-2.6-free',
198+
},
199+
);
200+
} finally {
201+
console.log = origLog;
202+
}
203+
204+
expect(JSON.parse(captured).request.model).toBe('music-2.6-free');
205+
});
206+
207+
it('requires prompt for instrumental generation', async () => {
208+
await expect(
209+
generateCommand.execute(
210+
{ ...baseConfig, dryRun: true },
211+
{ ...baseFlags, dryRun: true, instrumental: true },
212+
),
213+
).rejects.toThrow('--prompt is required with --instrumental');
214+
});
215+
184216
it('rejects invalid audio format', async () => {
185217
await expect(
186218
generateCommand.execute(

0 commit comments

Comments
 (0)