elevenlabs-tts.mjs
138 lines 4.5 KB
Raw
sha256:94ec65bd2b200240ac785a97cf14c5db066832bd608a24d6a9c151f17b918b02 feat(calendar): hosted bridge/gateway route parity and time… Human minor ⚠ breaking 22 hours ago
1 /**
2 * ElevenLabs text-to-speech bridge.
3 *
4 * Used as a backup voice path for non-HeyGen audio (podcasts, audio-only newsletter).
5 * For the main video pipeline, the voice clone lives INSIDE HeyGen via their
6 * ElevenLabs integration — the script is sent to HeyGen, not here.
7 *
8 * Calls POST /v1/text-to-speech/{voice_id} which returns audio bytes (mp3 by default).
9 * We keep the response as ArrayBuffer; the caller writes it to disk or uploads to S3.
10 *
11 * @typedef {object} ElevenLabsTtsArgs
12 * @property {string} text ≤5000 chars per request.
13 * @property {string} voiceId Pro Voice Clone ID.
14 * @property {string} [modelId] Default 'eleven_multilingual_v2'.
15 * @property {number} [stability] 0.0–1.0 (default 0.5).
16 * @property {number} [similarityBoost] 0.0–1.0 (default 0.75).
17 *
18 * @typedef {object} ElevenLabsClientOptions
19 * @property {string} apiKey
20 * @property {string} [baseUrl] Default 'https://api.elevenlabs.io'.
21 * @property {typeof fetch} [fetch]
22 */
23
24 const EL_DEFAULT_BASE_URL = 'https://api.elevenlabs.io';
25
26 /**
27 * @param {ElevenLabsClientOptions} opts
28 * @returns {{
29 * tts: (args: ElevenLabsTtsArgs) => Promise<{ audio: ArrayBuffer, contentType: string, voiceId: string, modelId: string }>,
30 * getQuota: () => Promise<{ characterCount: number, characterLimit: number, remaining: number }>,
31 * }}
32 */
33 export function createElevenLabsClient(opts) {
34 const {
35 apiKey,
36 baseUrl = EL_DEFAULT_BASE_URL,
37 fetch: fetchImpl = globalThis.fetch,
38 } = opts;
39
40 if (!apiKey) throw new Error('createElevenLabsClient: apiKey required');
41 if (typeof fetchImpl !== 'function') throw new Error('createElevenLabsClient: fetch required');
42
43 async function tts(args) {
44 validateTtsArgs(args);
45 const voiceId = args.voiceId;
46 const modelId = args.modelId ?? 'eleven_multilingual_v2';
47
48 const body = {
49 text: args.text,
50 model_id: modelId,
51 voice_settings: {
52 stability: clamp01(args.stability ?? 0.5),
53 similarity_boost: clamp01(args.similarityBoost ?? 0.75),
54 },
55 };
56
57 const res = await fetchImpl(`${baseUrl}/v1/text-to-speech/${encodeURIComponent(voiceId)}`, {
58 method: 'POST',
59 headers: {
60 'xi-api-key': apiKey,
61 'Content-Type': 'application/json',
62 Accept: 'audio/mpeg',
63 },
64 body: JSON.stringify(body),
65 });
66
67 if (!res.ok) {
68 const errBody = await safeJson(res);
69 throw Object.assign(
70 new Error(`elevenlabs_${res.status}: ${errBody?.detail?.message || errBody?.message || 'tts failed'}`),
71 { code: `ELEVENLABS_${res.status}`, status: res.status, body: errBody }
72 );
73 }
74
75 const audio = await res.arrayBuffer();
76 const contentType = res.headers?.get?.('content-type') ?? 'audio/mpeg';
77
78 return { audio, contentType, voiceId, modelId };
79 }
80
81 async function getQuota() {
82 const res = await fetchImpl(`${baseUrl}/v1/user/subscription`, {
83 method: 'GET',
84 headers: { 'xi-api-key': apiKey, Accept: 'application/json' },
85 });
86 if (!res.ok) {
87 const body = await safeJson(res);
88 throw Object.assign(new Error(`elevenlabs_quota_${res.status}`), {
89 code: `ELEVENLABS_QUOTA_${res.status}`,
90 status: res.status,
91 body,
92 });
93 }
94 const data = await res.json();
95 const characterCount = Number(data?.character_count ?? 0);
96 const characterLimit = Number(data?.character_limit ?? 0);
97 return {
98 characterCount,
99 characterLimit,
100 remaining: Math.max(0, characterLimit - characterCount),
101 };
102 }
103
104 return { tts, getQuota };
105 }
106
107 function validateTtsArgs(args) {
108 if (!args || typeof args !== 'object') {
109 throw Object.assign(new Error('elevenlabs_invalid_args'), { code: 'ELEVENLABS_INVALID_ARGS' });
110 }
111 if (typeof args.text !== 'string' || !args.text.trim()) {
112 throw Object.assign(new Error('elevenlabs_invalid_text'), { code: 'ELEVENLABS_INVALID_TEXT' });
113 }
114 if (args.text.length > 5000) {
115 throw Object.assign(new Error('elevenlabs_text_too_long: chunk into <=5000-char calls'), {
116 code: 'ELEVENLABS_TEXT_TOO_LONG',
117 });
118 }
119 if (typeof args.voiceId !== 'string' || !args.voiceId.trim()) {
120 throw Object.assign(new Error('elevenlabs_invalid_voice_id'), {
121 code: 'ELEVENLABS_INVALID_VOICE_ID',
122 });
123 }
124 }
125
126 function clamp01(n) {
127 const x = Number(n);
128 if (!Number.isFinite(x)) return 0.5;
129 return Math.max(0, Math.min(1, x));
130 }
131
132 async function safeJson(res) {
133 try {
134 return await res.json();
135 } catch (_e) {
136 return null;
137 }
138 }
File History 1 commit
sha256:94ec65bd2b200240ac785a97cf14c5db066832bd608a24d6a9c151f17b918b02 feat(calendar): hosted bridge/gateway route parity and time… Human minor 22 hours ago