From 952d6f1349a90b0af97f900a6d40369928da9573 Mon Sep 17 00:00:00 2001 From: NianJiuZst <3235467914@qq.com> Date: Wed, 15 Jul 2026 14:05:42 +0800 Subject: [PATCH] fix: support music covers and surface stream errors --- SDK.md | 16 ++++ src/sdk/music/index.ts | 136 ++++++++++++++++++++++++------ src/utils/audio-stream.ts | 78 +++++++++++++++-- test/sdk/music.test.ts | 145 +++++++++++++++++++++++++++++++- test/utils/audio-stream.test.ts | 56 ++++++++++++ 5 files changed, 393 insertions(+), 38 deletions(-) create mode 100644 test/utils/audio-stream.test.ts diff --git a/SDK.md b/SDK.md index 4dbf3528..75020248 100644 --- a/SDK.md +++ b/SDK.md @@ -138,6 +138,22 @@ for await (const chunk of stream) { // chunk contains decoded audio bytes } +// One-step cover — lyrics are extracted from the reference audio when omitted +const cover = await sdk.music.generate({ + model: 'music-cover-free', + prompt: 'Jazz piano trio with a warm intimate vocal', + audio_url: 'https://example.com/reference.mp3', + output_format: 'url', +}); + +// Two-step cover — use a feature ID returned by the cover preprocess API +const rewrittenCover = await sdk.music.generate({ + model: 'music-cover', + prompt: 'Acoustic folk with gentle strings and soft vocals', + cover_feature_id: 'feature-id-from-preprocess', + lyrics: '[Verse]\nThese are the rewritten lyrics for the cover', +}); + // Structured prompt const structured = await sdk.music.generate({ prompt: 'A beautiful song', diff --git a/src/sdk/music/index.ts b/src/sdk/music/index.ts index d2c79f36..f35ce1ac 100644 --- a/src/sdk/music/index.ts +++ b/src/sdk/music/index.ts @@ -7,7 +7,11 @@ import { ModelPartial } from "../types"; import { SDKError } from "../../errors/base"; import { ExitCode } from "../../errors/codes"; import { toMerged } from "es-toolkit/object"; -import { MUSIC_GENERATE_MODELS, musicGenerateModel } from "../../commands/music/models"; +import { + MUSIC_COVER_MODELS, + MUSIC_GENERATE_MODELS, + musicGenerateModel, +} from "../../commands/music/models"; import { decodeAudioStream } from "../../utils/audio-stream"; function hexToBuffer(hex: string): Buffer { @@ -189,32 +193,19 @@ export class MusicSDK extends Client { const { model, output_format, stream, prompt, lyrics, is_instrumental, lyrics_optimizer, } = normalized; - if (is_instrumental && lyrics) { - throw new SDKError('Cannot use is_instrumental with lyrics', ExitCode.USAGE); - } - - if (lyrics_optimizer && (lyrics || is_instrumental)) { - throw new SDKError('Cannot use lyrics_optimizer with lyrics or is_instrumental', ExitCode.USAGE); - } - - if (!prompt && !lyrics && !is_instrumental && !lyrics_optimizer) { - 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); - } - - if (model && !MUSIC_GENERATE_MODELS.includes(model as typeof MUSIC_GENERATE_MODELS[number])) { + const effectiveModel = model ?? musicGenerateModel(this.config); + const isGenerateModel = MUSIC_GENERATE_MODELS.includes( + effectiveModel as typeof MUSIC_GENERATE_MODELS[number], + ); + const isCoverModel = MUSIC_COVER_MODELS.includes( + effectiveModel as typeof MUSIC_COVER_MODELS[number], + ); + if (!isGenerateModel && !isCoverModel) { throw new SDKError( - `Invalid model: ${model}. Valid models are ${MUSIC_GENERATE_MODELS.join(', ')}.`, + `Invalid model: ${effectiveModel}. Valid models are ${[ + ...MUSIC_GENERATE_MODELS, + ...MUSIC_COVER_MODELS, + ].join(', ')}.`, ExitCode.USAGE, ); } @@ -233,13 +224,102 @@ export class MusicSDK extends Client { ); } - const targetPrompt = this.buildPrompt({ + let targetPrompt = this.buildPrompt({ ...params, is_instrumental, }); + if (isCoverModel) { + if (is_instrumental) { + throw new SDKError( + 'is_instrumental is only supported by music generation models', + ExitCode.USAGE, + ); + } + if (lyrics_optimizer) { + throw new SDKError( + 'lyrics_optimizer is only supported by music generation models', + ExitCode.USAGE, + ); + } + delete normalized.is_instrumental; + delete normalized.lyrics_optimizer; + + targetPrompt = targetPrompt?.trim(); + const coverPromptLength = targetPrompt?.length ?? 0; + if (coverPromptLength < 10 || coverPromptLength > 300) { + throw new SDKError( + 'prompt must be between 10 and 300 characters for music cover models', + ExitCode.USAGE, + ); + } + + const hasAudioUrl = Boolean(normalized.audio_url?.trim()); + const hasAudioBase64 = Boolean(normalized.audio_base64?.trim()); + const hasCoverFeatureId = Boolean(normalized.cover_feature_id?.trim()); + const sourceCount = [hasAudioUrl, hasAudioBase64, hasCoverFeatureId] + .filter(Boolean).length; + if (sourceCount !== 1) { + throw new SDKError( + 'Exactly one of audio_url, audio_base64, or cover_feature_id is required for music cover models', + ExitCode.USAGE, + ); + } + if (hasAudioUrl) normalized.audio_url = normalized.audio_url?.trim(); + else delete normalized.audio_url; + if (hasAudioBase64) normalized.audio_base64 = normalized.audio_base64?.trim(); + else delete normalized.audio_base64; + if (hasCoverFeatureId) { + normalized.cover_feature_id = normalized.cover_feature_id?.trim(); + } else { + delete normalized.cover_feature_id; + } + + const coverLyrics = lyrics?.trim(); + const lyricsLength = coverLyrics?.length ?? 0; + if (hasCoverFeatureId && lyricsLength === 0) { + throw new SDKError( + 'lyrics is required with cover_feature_id', + ExitCode.USAGE, + ); + } + if (coverLyrics) normalized.lyrics = coverLyrics; + else delete normalized.lyrics; + if (lyricsLength > 0 && (lyricsLength < 10 || lyricsLength > 1000)) { + throw new SDKError( + 'lyrics must be between 10 and 1000 characters for music cover models', + ExitCode.USAGE, + ); + } + } else { + if (normalized.audio_url || normalized.audio_base64 || normalized.cover_feature_id) { + throw new SDKError( + 'audio_url, audio_base64, and cover_feature_id are only supported by music cover models', + ExitCode.USAGE, + ); + } + if (is_instrumental && lyrics) { + throw new SDKError('Cannot use is_instrumental with lyrics', ExitCode.USAGE); + } + if (lyrics_optimizer && (lyrics || is_instrumental)) { + throw new SDKError('Cannot use lyrics_optimizer with lyrics or is_instrumental', ExitCode.USAGE); + } + if (!prompt && !lyrics && !is_instrumental && !lyrics_optimizer) { + 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); + } + } + return toMerged({ - model: musicGenerateModel(this.config), + model: effectiveModel, audio_setting: { format: 'mp3', sample_rate: 44100, diff --git a/src/utils/audio-stream.ts b/src/utils/audio-stream.ts index 06c4c22a..a6bc2d7d 100644 --- a/src/utils/audio-stream.ts +++ b/src/utils/audio-stream.ts @@ -1,25 +1,91 @@ import { parseSSE } from '../client/stream'; +import { mapApiError, type ApiErrorBody } from '../errors/api'; +import { CLIError } from '../errors/base'; +import { ExitCode } from '../errors/codes'; -interface SseAudioPayload { +interface AudioPayload extends ApiErrorBody { data?: { audio?: string; status?: number }; } +function decodeHexAudio(hex: string): Uint8Array { + if (!/^[0-9a-fA-F]+$/.test(hex)) { + throw new CLIError( + 'API returned invalid audio data (not valid hex).', + ExitCode.GENERAL, + ); + } + if (hex.length % 2 !== 0) { + throw new CLIError( + 'API returned truncated audio data (odd-length hex string).', + ExitCode.GENERAL, + ); + } + return Uint8Array.from(Buffer.from(hex, 'hex')); +} + +function throwIfApiError(response: Response, payload: AudioPayload): void { + const statusCode = payload.base_resp?.status_code; + if ((statusCode !== undefined && statusCode !== 0) || payload.error) { + throw mapApiError(response.status, payload, response.url || undefined); + } +} + +function missingAudioError(): CLIError { + return new CLIError( + 'API stream ended without audio data.', + ExitCode.GENERAL, + ); +} + export async function* decodeAudioStream( response: Response, ): AsyncGenerator> { + const contentType = response.headers.get('content-type') || ''; + if (!contentType.toLowerCase().includes('text/event-stream')) { + let payload: AudioPayload; + try { + payload = await response.json() as AudioPayload; + } catch { + throw new CLIError( + `Expected SSE audio stream but got content-type "${contentType || 'unknown'}".`, + ExitCode.GENERAL, + ); + } + + throwIfApiError(response, payload); + + const hex = payload.data?.audio; + if (!hex) throw missingAudioError(); + yield decodeHexAudio(hex); + return; + } + + let receivedAudio = false; for await (const event of parseSSE(response)) { if (!event.data || event.data === '[DONE]') break; - let parsed: SseAudioPayload; - try { parsed = JSON.parse(event.data); } catch { continue; } + let parsed: AudioPayload; + try { + parsed = JSON.parse(event.data) as AudioPayload; + } catch (error) { + throw new CLIError( + `Failed to parse audio stream chunk: ${error instanceof Error ? error.message : String(error)}`, + ExitCode.GENERAL, + ); + } - if (parsed.data?.status === 2) continue; + throwIfApiError(response, parsed); const hex = parsed.data?.audio; - if (!hex) continue; + if (hex) { + receivedAudio = true; + yield decodeHexAudio(hex); + } - yield Uint8Array.from(Buffer.from(hex, 'hex')); + if (parsed.data?.status === 2) break; } + + if (!receivedAudio) throw missingAudioError(); } export async function pipeAudioStream(response: Response): Promise { diff --git a/test/sdk/music.test.ts b/test/sdk/music.test.ts index ccde5632..e1807e34 100644 --- a/test/sdk/music.test.ts +++ b/test/sdk/music.test.ts @@ -17,6 +17,14 @@ function makeMusicResponse(hexAudio?: string): MusicResponse { }; } +async function collectAudio( + stream: AsyncGenerator>, +): Promise { + const chunks: Uint8Array[] = []; + for await (const chunk of stream) chunks.push(chunk); + return Buffer.concat(chunks); +} + describe('MiniMaxSDK.music', () => { let server: MockServer; @@ -64,7 +72,7 @@ describe('MiniMaxSDK.music', () => { '/v1/music_generation': () => sseResponse([ { data: JSON.stringify({ data: { audio: '4142', status: 1 } }) }, { data: JSON.stringify({ data: { audio: '4344', status: 1 } }) }, - { data: JSON.stringify({ data: { status: 2 } }) }, + { data: JSON.stringify({ data: { audio: '4546', status: 2 } }) }, ]), }, }); @@ -78,10 +86,100 @@ describe('MiniMaxSDK.music', () => { lyrics: '[verse] Hello world', stream: true, }); - const chunks: Uint8Array[] = []; - for await (const chunk of stream) chunks.push(chunk); + const audio = await collectAudio(stream); + + expect(audio.toString()).toBe('ABCDEF'); + }); + + it('throws when a streaming request returns a JSON business error', async () => { + server = createMockServer({ + routes: { + '/v1/music_generation': () => jsonResponse({ + base_resp: { + status_code: 1008, + status_msg: 'insufficient balance', + }, + }), + }, + }); + + const sdk = new MiniMaxSDK({ + apiKey: 'test-key', + baseUrl: server.url, + }); + const stream = await sdk.music.generate({ + prompt: 'Upbeat pop', + lyrics: '[verse] Hello world', + stream: true, + }); + + await expect(collectAudio(stream)).rejects.toThrow('insufficient balance'); + }); + + it('supports one-step covers without replacement lyrics', async () => { + let requestBody: Record | undefined; + server = createMockServer({ + routes: { + '/v1/music_generation': async (req) => { + requestBody = await req.json() as Record; + return jsonResponse({ + data: { audio_url: 'https://example.com/cover.mp3', status: 2 }, + base_resp: { status_code: 0, status_msg: 'success' }, + }); + }, + }, + }); + + const sdk = new MiniMaxSDK({ + apiKey: 'test-key', + baseUrl: server.url, + }); + const result = await sdk.music.generate({ + model: 'music-cover-free', + prompt: 'Jazz piano trio with warm intimate vocals', + audio_url: 'https://example.com/reference.mp3', + is_instrumental: false, + lyrics_optimizer: false, + output_format: 'url', + }); - expect(Buffer.concat(chunks).toString()).toBe('ABCD'); + expect(result.data.audio_url).toBe('https://example.com/cover.mp3'); + expect(requestBody?.model).toBe('music-cover-free'); + expect(requestBody?.audio_url).toBe('https://example.com/reference.mp3'); + expect(requestBody?.lyrics).toBeUndefined(); + expect(requestBody?.is_instrumental).toBeUndefined(); + expect(requestBody?.lyrics_optimizer).toBeUndefined(); + }); + + it('supports two-step covers with replacement lyrics', async () => { + let requestBody: Record | undefined; + server = createMockServer({ + routes: { + '/v1/music_generation': async (req) => { + requestBody = await req.json() as Record; + return jsonResponse({ + data: { audio: '4142', status: 2 }, + base_resp: { status_code: 0, status_msg: 'success' }, + }); + }, + }, + }); + + const sdk = new MiniMaxSDK({ + apiKey: 'test-key', + baseUrl: server.url, + }); + await sdk.music.generate({ + model: 'music-cover', + prompt: 'Acoustic folk with gentle strings and soft vocals', + cover_feature_id: 'cover-feature-id', + lyrics: '[Verse] These are replacement lyrics for the song', + }); + + expect(requestBody?.model).toBe('music-cover'); + expect(requestBody?.cover_feature_id).toBe('cover-feature-id'); + expect(requestBody?.lyrics).toContain('replacement lyrics'); + expect(requestBody?.audio_url).toBeUndefined(); }); }); @@ -177,6 +275,45 @@ describe('MusicSDK.validateParams', () => { ).rejects.toThrow('Invalid model'); }); + it('requires exactly one audio source for cover models', async () => { + await expect( + sdk.generate({ + model: 'music-cover-free', + prompt: 'Jazz piano trio with warm intimate vocals', + }), + ).rejects.toThrow('Exactly one of audio_url, audio_base64, or cover_feature_id'); + + await expect( + sdk.generate({ + model: 'music-cover', + prompt: 'Jazz piano trio with warm intimate vocals', + audio_url: 'https://example.com/reference.mp3', + cover_feature_id: 'cover-feature-id', + }), + ).rejects.toThrow('Exactly one of audio_url, audio_base64, or cover_feature_id'); + }); + + it('requires replacement lyrics with cover_feature_id', async () => { + await expect( + sdk.generate({ + model: 'music-cover', + prompt: 'Jazz piano trio with warm intimate vocals', + cover_feature_id: 'cover-feature-id', + }), + ).rejects.toThrow('lyrics is required with cover_feature_id'); + }); + + it('rejects generation-only options for cover models', async () => { + await expect( + sdk.generate({ + model: 'music-cover', + prompt: 'Jazz piano trio with warm intimate vocals', + audio_url: 'https://example.com/reference.mp3', + is_instrumental: true, + }), + ).rejects.toThrow('is_instrumental is only supported by music generation models'); + }); + it('throws on invalid output_format', async () => { await expect( sdk.generate({ diff --git a/test/utils/audio-stream.test.ts b/test/utils/audio-stream.test.ts new file mode 100644 index 00000000..23bdefd4 --- /dev/null +++ b/test/utils/audio-stream.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'bun:test'; +import { decodeAudioStream } from '../../src/utils/audio-stream'; +import { jsonResponse, sseResponse } from '../helpers/mock-server'; + +async function collectAudio(response: Response): Promise { + const chunks: Uint8Array[] = []; + for await (const chunk of decodeAudioStream(response)) chunks.push(chunk); + return Buffer.concat(chunks); +} + +describe('decodeAudioStream', () => { + it('decodes a non-SSE JSON success response instead of returning empty audio', async () => { + const audio = await collectAudio(jsonResponse({ + data: { audio: '414243', status: 2 }, + base_resp: { status_code: 0, status_msg: 'success' }, + })); + + expect(audio.toString()).toBe('ABC'); + }); + + it('propagates API errors from SSE events', async () => { + const response = sseResponse([{ + data: JSON.stringify({ + base_resp: { status_code: 1028, status_msg: 'quota exhausted' }, + }), + }]); + + await expect(collectAudio(response)).rejects.toThrow('Quota exhausted'); + }); + + it('rejects malformed SSE JSON', async () => { + const response = sseResponse([{ data: '{not-json' }]); + + await expect(collectAudio(response)).rejects.toThrow( + 'Failed to parse audio stream chunk', + ); + }); + + it('rejects invalid audio hex', async () => { + const response = sseResponse([{ + data: JSON.stringify({ data: { audio: 'not-hex', status: 1 } }), + }]); + + await expect(collectAudio(response)).rejects.toThrow('not valid hex'); + }); + + it('rejects a completed stream that contains no audio', async () => { + const response = sseResponse([{ + data: JSON.stringify({ data: { status: 2 } }), + }]); + + await expect(collectAudio(response)).rejects.toThrow( + 'API stream ended without audio data', + ); + }); +});