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
16 changes: 16 additions & 0 deletions SDK.md
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
136 changes: 108 additions & 28 deletions src/sdk/music/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
);
}
Expand All @@ -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,
Expand Down
78 changes: 72 additions & 6 deletions src/utils/audio-stream.ts
Original file line number Diff line number Diff line change
@@ -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<ArrayBuffer> {
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<Uint8Array<ArrayBuffer>> {
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<void> {
Expand Down
Loading
Loading