Skip to content

Commit 952d6f1

Browse files
committed
fix: support music covers and surface stream errors
1 parent ab29137 commit 952d6f1

5 files changed

Lines changed: 393 additions & 38 deletions

File tree

SDK.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,22 @@ for await (const chunk of stream) {
138138
// chunk contains decoded audio bytes
139139
}
140140

141+
// One-step cover — lyrics are extracted from the reference audio when omitted
142+
const cover = await sdk.music.generate({
143+
model: 'music-cover-free',
144+
prompt: 'Jazz piano trio with a warm intimate vocal',
145+
audio_url: 'https://example.com/reference.mp3',
146+
output_format: 'url',
147+
});
148+
149+
// Two-step cover — use a feature ID returned by the cover preprocess API
150+
const rewrittenCover = await sdk.music.generate({
151+
model: 'music-cover',
152+
prompt: 'Acoustic folk with gentle strings and soft vocals',
153+
cover_feature_id: 'feature-id-from-preprocess',
154+
lyrics: '[Verse]\nThese are the rewritten lyrics for the cover',
155+
});
156+
141157
// Structured prompt
142158
const structured = await sdk.music.generate({
143159
prompt: 'A beautiful song',

src/sdk/music/index.ts

Lines changed: 108 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@ 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 { MUSIC_GENERATE_MODELS, musicGenerateModel } from "../../commands/music/models";
10+
import {
11+
MUSIC_COVER_MODELS,
12+
MUSIC_GENERATE_MODELS,
13+
musicGenerateModel,
14+
} from "../../commands/music/models";
1115
import { decodeAudioStream } from "../../utils/audio-stream";
1216

1317
function hexToBuffer(hex: string): Buffer {
@@ -189,32 +193,19 @@ export class MusicSDK extends Client {
189193
const {
190194
model, output_format, stream, prompt, lyrics, is_instrumental, lyrics_optimizer,
191195
} = normalized;
192-
if (is_instrumental && lyrics) {
193-
throw new SDKError('Cannot use is_instrumental with lyrics', ExitCode.USAGE);
194-
}
195-
196-
if (lyrics_optimizer && (lyrics || is_instrumental)) {
197-
throw new SDKError('Cannot use lyrics_optimizer with lyrics or is_instrumental', ExitCode.USAGE);
198-
}
199-
200-
if (!prompt && !lyrics && !is_instrumental && !lyrics_optimizer) {
201-
throw new SDKError('At least one of prompt or lyrics or is_instrumental or lyrics_optimizer is required', ExitCode.USAGE);
202-
}
203-
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-
211-
if (!is_instrumental && !lyrics_optimizer && !lyrics?.trim()) {
212-
throw new SDKError('lyrics is required', ExitCode.USAGE);
213-
}
214-
215-
if (model && !MUSIC_GENERATE_MODELS.includes(model as typeof MUSIC_GENERATE_MODELS[number])) {
196+
const effectiveModel = model ?? musicGenerateModel(this.config);
197+
const isGenerateModel = MUSIC_GENERATE_MODELS.includes(
198+
effectiveModel as typeof MUSIC_GENERATE_MODELS[number],
199+
);
200+
const isCoverModel = MUSIC_COVER_MODELS.includes(
201+
effectiveModel as typeof MUSIC_COVER_MODELS[number],
202+
);
203+
if (!isGenerateModel && !isCoverModel) {
216204
throw new SDKError(
217-
`Invalid model: ${model}. Valid models are ${MUSIC_GENERATE_MODELS.join(', ')}.`,
205+
`Invalid model: ${effectiveModel}. Valid models are ${[
206+
...MUSIC_GENERATE_MODELS,
207+
...MUSIC_COVER_MODELS,
208+
].join(', ')}.`,
218209
ExitCode.USAGE,
219210
);
220211
}
@@ -233,13 +224,102 @@ export class MusicSDK extends Client {
233224
);
234225
}
235226

236-
const targetPrompt = this.buildPrompt({
227+
let targetPrompt = this.buildPrompt({
237228
...params,
238229
is_instrumental,
239230
});
240231

232+
if (isCoverModel) {
233+
if (is_instrumental) {
234+
throw new SDKError(
235+
'is_instrumental is only supported by music generation models',
236+
ExitCode.USAGE,
237+
);
238+
}
239+
if (lyrics_optimizer) {
240+
throw new SDKError(
241+
'lyrics_optimizer is only supported by music generation models',
242+
ExitCode.USAGE,
243+
);
244+
}
245+
delete normalized.is_instrumental;
246+
delete normalized.lyrics_optimizer;
247+
248+
targetPrompt = targetPrompt?.trim();
249+
const coverPromptLength = targetPrompt?.length ?? 0;
250+
if (coverPromptLength < 10 || coverPromptLength > 300) {
251+
throw new SDKError(
252+
'prompt must be between 10 and 300 characters for music cover models',
253+
ExitCode.USAGE,
254+
);
255+
}
256+
257+
const hasAudioUrl = Boolean(normalized.audio_url?.trim());
258+
const hasAudioBase64 = Boolean(normalized.audio_base64?.trim());
259+
const hasCoverFeatureId = Boolean(normalized.cover_feature_id?.trim());
260+
const sourceCount = [hasAudioUrl, hasAudioBase64, hasCoverFeatureId]
261+
.filter(Boolean).length;
262+
if (sourceCount !== 1) {
263+
throw new SDKError(
264+
'Exactly one of audio_url, audio_base64, or cover_feature_id is required for music cover models',
265+
ExitCode.USAGE,
266+
);
267+
}
268+
if (hasAudioUrl) normalized.audio_url = normalized.audio_url?.trim();
269+
else delete normalized.audio_url;
270+
if (hasAudioBase64) normalized.audio_base64 = normalized.audio_base64?.trim();
271+
else delete normalized.audio_base64;
272+
if (hasCoverFeatureId) {
273+
normalized.cover_feature_id = normalized.cover_feature_id?.trim();
274+
} else {
275+
delete normalized.cover_feature_id;
276+
}
277+
278+
const coverLyrics = lyrics?.trim();
279+
const lyricsLength = coverLyrics?.length ?? 0;
280+
if (hasCoverFeatureId && lyricsLength === 0) {
281+
throw new SDKError(
282+
'lyrics is required with cover_feature_id',
283+
ExitCode.USAGE,
284+
);
285+
}
286+
if (coverLyrics) normalized.lyrics = coverLyrics;
287+
else delete normalized.lyrics;
288+
if (lyricsLength > 0 && (lyricsLength < 10 || lyricsLength > 1000)) {
289+
throw new SDKError(
290+
'lyrics must be between 10 and 1000 characters for music cover models',
291+
ExitCode.USAGE,
292+
);
293+
}
294+
} else {
295+
if (normalized.audio_url || normalized.audio_base64 || normalized.cover_feature_id) {
296+
throw new SDKError(
297+
'audio_url, audio_base64, and cover_feature_id are only supported by music cover models',
298+
ExitCode.USAGE,
299+
);
300+
}
301+
if (is_instrumental && lyrics) {
302+
throw new SDKError('Cannot use is_instrumental with lyrics', ExitCode.USAGE);
303+
}
304+
if (lyrics_optimizer && (lyrics || is_instrumental)) {
305+
throw new SDKError('Cannot use lyrics_optimizer with lyrics or is_instrumental', ExitCode.USAGE);
306+
}
307+
if (!prompt && !lyrics && !is_instrumental && !lyrics_optimizer) {
308+
throw new SDKError('At least one of prompt or lyrics or is_instrumental or lyrics_optimizer is required', ExitCode.USAGE);
309+
}
310+
if ((is_instrumental || lyrics_optimizer) && !prompt?.trim()) {
311+
throw new SDKError(
312+
'prompt is required with is_instrumental or lyrics_optimizer',
313+
ExitCode.USAGE,
314+
);
315+
}
316+
if (!is_instrumental && !lyrics_optimizer && !lyrics?.trim()) {
317+
throw new SDKError('lyrics is required', ExitCode.USAGE);
318+
}
319+
}
320+
241321
return toMerged({
242-
model: musicGenerateModel(this.config),
322+
model: effectiveModel,
243323
audio_setting: {
244324
format: 'mp3',
245325
sample_rate: 44100,

src/utils/audio-stream.ts

Lines changed: 72 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,91 @@
11
import { parseSSE } from '../client/stream';
2+
import { mapApiError, type ApiErrorBody } from '../errors/api';
3+
import { CLIError } from '../errors/base';
4+
import { ExitCode } from '../errors/codes';
25

3-
interface SseAudioPayload {
6+
interface AudioPayload extends ApiErrorBody {
47
data?: { audio?: string; status?: number };
58
}
69

10+
function decodeHexAudio(hex: string): Uint8Array<ArrayBuffer> {
11+
if (!/^[0-9a-fA-F]+$/.test(hex)) {
12+
throw new CLIError(
13+
'API returned invalid audio data (not valid hex).',
14+
ExitCode.GENERAL,
15+
);
16+
}
17+
if (hex.length % 2 !== 0) {
18+
throw new CLIError(
19+
'API returned truncated audio data (odd-length hex string).',
20+
ExitCode.GENERAL,
21+
);
22+
}
23+
return Uint8Array.from(Buffer.from(hex, 'hex'));
24+
}
25+
26+
function throwIfApiError(response: Response, payload: AudioPayload): void {
27+
const statusCode = payload.base_resp?.status_code;
28+
if ((statusCode !== undefined && statusCode !== 0) || payload.error) {
29+
throw mapApiError(response.status, payload, response.url || undefined);
30+
}
31+
}
32+
33+
function missingAudioError(): CLIError {
34+
return new CLIError(
35+
'API stream ended without audio data.',
36+
ExitCode.GENERAL,
37+
);
38+
}
39+
740
export async function* decodeAudioStream(
841
response: Response,
942
): AsyncGenerator<Uint8Array<ArrayBuffer>> {
43+
const contentType = response.headers.get('content-type') || '';
44+
if (!contentType.toLowerCase().includes('text/event-stream')) {
45+
let payload: AudioPayload;
46+
try {
47+
payload = await response.json() as AudioPayload;
48+
} catch {
49+
throw new CLIError(
50+
`Expected SSE audio stream but got content-type "${contentType || 'unknown'}".`,
51+
ExitCode.GENERAL,
52+
);
53+
}
54+
55+
throwIfApiError(response, payload);
56+
57+
const hex = payload.data?.audio;
58+
if (!hex) throw missingAudioError();
59+
yield decodeHexAudio(hex);
60+
return;
61+
}
62+
63+
let receivedAudio = false;
1064
for await (const event of parseSSE(response)) {
1165
if (!event.data || event.data === '[DONE]') break;
1266

13-
let parsed: SseAudioPayload;
14-
try { parsed = JSON.parse(event.data); } catch { continue; }
67+
let parsed: AudioPayload;
68+
try {
69+
parsed = JSON.parse(event.data) as AudioPayload;
70+
} catch (error) {
71+
throw new CLIError(
72+
`Failed to parse audio stream chunk: ${error instanceof Error ? error.message : String(error)}`,
73+
ExitCode.GENERAL,
74+
);
75+
}
1576

16-
if (parsed.data?.status === 2) continue;
77+
throwIfApiError(response, parsed);
1778

1879
const hex = parsed.data?.audio;
19-
if (!hex) continue;
80+
if (hex) {
81+
receivedAudio = true;
82+
yield decodeHexAudio(hex);
83+
}
2084

21-
yield Uint8Array.from(Buffer.from(hex, 'hex'));
85+
if (parsed.data?.status === 2) break;
2286
}
87+
88+
if (!receivedAudio) throw missingAudioError();
2389
}
2490

2591
export async function pipeAudioStream(response: Response): Promise<void> {

0 commit comments

Comments
 (0)