Skip to content

Commit ffd8c41

Browse files
authored
Merge pull request #28 from cuio/feat/storyline-gemini-music-retention
feat: gemini retention review + music + scroll test + retention map
2 parents 7bcb757 + 07f16e8 commit ffd8c41

14 files changed

Lines changed: 2514 additions & 2 deletions

File tree

packages/core/src/elevenlabs/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,12 @@ export {
1717
export type { ElevenLabsVoice, SynthesizeOptions } from "./client.js";
1818
export { generateSoundEffect, clampSfxDuration, SFX_BOUNDS } from "./sfx.js";
1919
export type { GenerateSfxOptions, GenerateSfxResult } from "./sfx.js";
20+
export {
21+
generateMusic,
22+
generateMusicAndWait,
23+
getMusicJob,
24+
downloadMusic,
25+
clampMusicDuration,
26+
MUSIC_BOUNDS,
27+
} from "./music.js";
28+
export type { GenerateMusicOptions, MusicJob, MusicJobStatus } from "./music.js";
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
/**
2+
* ElevenLabs Music client (Eleven v3 Music). Unlike SFX which is synchronous
3+
* (request → mp3 in one round-trip), music is an async job:
4+
*
5+
* POST /v1/music → { music_id, status: "processing" }
6+
* GET /v1/music/:id → { status: "completed", audio_url }
7+
* GET audio_url → mp3 bytes
8+
*
9+
* Callers want either the bytes (most common) or the job id (for progress
10+
* UIs). `generateMusicAndWait` does the full poll-then-download flow with
11+
* geometric backoff. The studio route exposes the job id immediately so the
12+
* UI can show "generating…" without freezing the request.
13+
*/
14+
15+
import { ElevenLabsError } from "./client.js";
16+
17+
const API_BASE = "https://api.elevenlabs.io/v1";
18+
19+
/** Music length bounds. The API supports up to ~5min in practice; we cap
20+
* shorter to avoid runaway generations on user typos. */
21+
export const MUSIC_BOUNDS = {
22+
durationMin: 10,
23+
durationMax: 300,
24+
promptMaxChars: 1500,
25+
} as const;
26+
27+
export type MusicJobStatus = "processing" | "completed" | "failed";
28+
29+
export interface MusicJob {
30+
id: string;
31+
status: MusicJobStatus;
32+
/** Set once status === "completed". Direct-download URL for the mp3. */
33+
audioUrl?: string;
34+
/** Reported by the API on failed jobs. */
35+
errorMessage?: string;
36+
}
37+
38+
export interface GenerateMusicOptions {
39+
durationMs?: number;
40+
outputFormat?: "mp3_44100_128" | "mp3_44100_192";
41+
}
42+
43+
export function clampMusicDuration(durationSeconds: number): number {
44+
if (!Number.isFinite(durationSeconds)) return 60;
45+
return Math.max(MUSIC_BOUNDS.durationMin, Math.min(MUSIC_BOUNDS.durationMax, durationSeconds));
46+
}
47+
48+
/**
49+
* Submit a music-generation request. Returns the job id immediately so the
50+
* caller can show progress. Caller must poll `getMusicJob` (or use
51+
* `generateMusicAndWait`) to retrieve the audio.
52+
*/
53+
export async function generateMusic(
54+
apiKey: string,
55+
prompt: string,
56+
opts: GenerateMusicOptions = {},
57+
): Promise<MusicJob> {
58+
if (!prompt || !prompt.trim()) {
59+
throw new ElevenLabsError("generateMusic: prompt is required");
60+
}
61+
const trimmed = prompt.trim();
62+
if (trimmed.length > MUSIC_BOUNDS.promptMaxChars) {
63+
throw new ElevenLabsError(
64+
`generateMusic: prompt too long (max ${MUSIC_BOUNDS.promptMaxChars} chars)`,
65+
);
66+
}
67+
const body: Record<string, unknown> = { prompt: trimmed };
68+
if (typeof opts.durationMs === "number") {
69+
body.music_length_ms = Math.round(
70+
Math.max(
71+
MUSIC_BOUNDS.durationMin * 1000,
72+
Math.min(MUSIC_BOUNDS.durationMax * 1000, opts.durationMs),
73+
),
74+
);
75+
}
76+
if (opts.outputFormat) body.output_format = opts.outputFormat;
77+
78+
const res = await fetch(`${API_BASE}/music`, {
79+
method: "POST",
80+
headers: {
81+
"xi-api-key": apiKey,
82+
"Content-Type": "application/json",
83+
Accept: "application/json",
84+
},
85+
body: JSON.stringify(body),
86+
});
87+
if (!res.ok) {
88+
let detail = "";
89+
try {
90+
const text = await res.text();
91+
detail = text.length > 500 ? text.slice(0, 500) + "…" : text;
92+
} catch {
93+
/* ignore */
94+
}
95+
throw new ElevenLabsError(
96+
`generateMusic: ${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`,
97+
res.status,
98+
);
99+
}
100+
const json = (await res.json()) as {
101+
music_id?: string;
102+
status?: MusicJobStatus;
103+
audio_url?: string;
104+
};
105+
if (!json.music_id) {
106+
throw new ElevenLabsError("generateMusic: response missing music_id");
107+
}
108+
return {
109+
id: json.music_id,
110+
status: json.status ?? "processing",
111+
...(json.audio_url ? { audioUrl: json.audio_url } : {}),
112+
};
113+
}
114+
115+
export async function getMusicJob(apiKey: string, jobId: string): Promise<MusicJob> {
116+
const res = await fetch(`${API_BASE}/music/${encodeURIComponent(jobId)}`, {
117+
headers: { "xi-api-key": apiKey, Accept: "application/json" },
118+
});
119+
if (!res.ok) {
120+
throw new ElevenLabsError(`getMusicJob: ${res.status} ${res.statusText}`, res.status);
121+
}
122+
const json = (await res.json()) as {
123+
music_id?: string;
124+
status?: MusicJobStatus;
125+
audio_url?: string;
126+
error_message?: string;
127+
};
128+
return {
129+
id: json.music_id ?? jobId,
130+
status: json.status ?? "processing",
131+
...(json.audio_url ? { audioUrl: json.audio_url } : {}),
132+
...(json.error_message ? { errorMessage: json.error_message } : {}),
133+
};
134+
}
135+
136+
/**
137+
* Submit + poll until completed. Geometric backoff: 2s, 4s, 8s, max 30s.
138+
* Total wait capped at `maxWaitMs` (default 5min). Throws ElevenLabsError on
139+
* failure or timeout.
140+
*/
141+
export async function generateMusicAndWait(
142+
apiKey: string,
143+
prompt: string,
144+
opts: GenerateMusicOptions & { maxWaitMs?: number; onProgress?: (job: MusicJob) => void } = {},
145+
): Promise<{ jobId: string; audioUrl: string }> {
146+
const job = await generateMusic(apiKey, prompt, opts);
147+
if (job.status === "completed" && job.audioUrl) {
148+
return { jobId: job.id, audioUrl: job.audioUrl };
149+
}
150+
if (job.status === "failed") {
151+
throw new ElevenLabsError(`generateMusic: job failed — ${job.errorMessage ?? "no detail"}`);
152+
}
153+
const maxWait = opts.maxWaitMs ?? 300_000;
154+
const start = Date.now();
155+
let delay = 2000;
156+
while (Date.now() - start < maxWait) {
157+
await new Promise((r) => setTimeout(r, delay));
158+
delay = Math.min(delay * 2, 30_000);
159+
const next = await getMusicJob(apiKey, job.id);
160+
opts.onProgress?.(next);
161+
if (next.status === "completed" && next.audioUrl) {
162+
return { jobId: next.id, audioUrl: next.audioUrl };
163+
}
164+
if (next.status === "failed") {
165+
throw new ElevenLabsError(`generateMusic: job failed — ${next.errorMessage ?? "no detail"}`);
166+
}
167+
}
168+
throw new ElevenLabsError(
169+
`generateMusic: timed out after ${maxWait}ms waiting for job ${job.id}`,
170+
);
171+
}
172+
173+
/**
174+
* Download a completed job's mp3 bytes. The audio_url is signed and short-
175+
* lived — call this immediately after `generateMusicAndWait` returns.
176+
*/
177+
export async function downloadMusic(audioUrl: string): Promise<Uint8Array> {
178+
const res = await fetch(audioUrl);
179+
if (!res.ok) {
180+
throw new ElevenLabsError(`downloadMusic: ${res.status} ${res.statusText}`, res.status);
181+
}
182+
const bytes = new Uint8Array(await res.arrayBuffer());
183+
return bytes;
184+
}

0 commit comments

Comments
 (0)