1 # Voiceover with ElevenLabs
2
3 Complete implementation guide for adding AI-generated voiceovers to videos using ElevenLabs TTS and FFmpeg audio mixing.
4
5 ## Table of Contents
6
7 1. [Setup](#setup)
8 2. [Generating Voiceover Audio](#generating-voiceover-audio)
9
10 3. [Voice Selection](#voice-selection)
11 4. [Mixing Audio into Video](#mixing-audio-into-video)
12
13 5. [Timed Voiceover Segments](#timed-voiceover-segments)
14 6. [Complete Pipeline Example](#complete-pipeline-example)
15
16 ---
17
18 ## Setup
19
20 ### 1. Connect ElevenLabs via Replit Integration
21
22 ElevenLabs is available as a Replit connector. Set it up through the integration system:
23
24 ```javascript
25
26 // In the code execution sandbox:
27
28 const results = await searchIntegrations("elevenlabs");
29
30 console.log(results);
31
32 // Propose the connector to the user (they'll complete OAuth)
33
34 await proposeIntegration("connector:ccfg_elevenlabs_...");
35
36 ```
37
38 After the user authorizes, on the next loop:
39
40 ```javascript
41
42 // Add the connection to the project
43
44 const addResult = await addIntegration("connection:conn_elevenlabs_...");
45
46 console.log(addResult);
47
48 ```
49
50 ### 2. Access Credentials
51
52 Use `listConnections` in the code execution sandbox to get the API key:
53
54 ```javascript
55
56 const conns = await listConnections('elevenlabs');
57
58 if (conns.length > 0) {
59
60 const apiKey = conns[0].settings.api_key;
61
62 // Use this key for ElevenLabs API calls
63
64 }
65
66 ```
67
68 ### 3. Install Dependencies
69
70 No special SDK is needed — ElevenLabs has a straightforward REST API. Use `fetch` or install the official SDK:
71
72 ```bash
73
74 pnpm --filter @workspace/scripts add elevenlabs
75
76 # or for the API server:
77
78 pnpm --filter @workspace/api-server add elevenlabs
79
80 ```
81
82 ---
83
84 ## Generating Voiceover Audio
85
86 ### Using the ElevenLabs REST API directly
87
88 ```typescript
89
90 import * as fs from 'fs';
91
92 async function generateVoiceover(
93
94 text: string,
95
96 outputPath: string,
97
98 apiKey: string,
99
100 voiceId = 'JBFqnCBsd6RMkjVDRZzb', // Default: "George" - warm, narrative voice
101
102 options: {
103
104 stability?: number;
105
106 similarityBoost?: number;
107
108 style?: number;
109
110 modelId?: string;
111
112 } = {}
113
114 ): Promise<string> {
115
116 const {
117
118 stability = 0.5,
119
120 similarityBoost = 0.75,
121
122 style = 0.0,
123
124 modelId = 'eleven_multilingual_v2'
125
126 } = options;
127
128 const response = await fetch(
129
130 `https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`,
131
132 {
133
134 method: 'POST',
135
136 headers: {
137
138 'Content-Type': 'application/json',
139
140 'xi-api-key': apiKey
141
142 },
143
144 body: JSON.stringify({
145
146 text,
147
148 model_id: modelId,
149
150 voice_settings: {
151
152 stability,
153
154 similarity_boost: similarityBoost,
155
156 style,
157
158 use_speaker_boost: true
159
160 }
161
162 })
163
164 }
165
166 );
167
168 if (!response.ok) {
169
170 const error = await response.text();
171
172 throw new Error(`ElevenLabs TTS failed: ${response.status} ${error}`);
173
174 }
175
176 const buffer = Buffer.from(await response.arrayBuffer());
177
178 await fs.promises.writeFile(outputPath, buffer);
179
180 return outputPath;
181
182 }
183
184 ```
185
186 ### Using the ElevenLabs SDK
187
188 ```typescript
189
190 import { ElevenLabsClient } from 'elevenlabs';
191
192 import * as fs from 'fs';
193
194 async function generateVoiceoverSDK(
195
196 text: string,
197
198 outputPath: string,
199
200 apiKey: string,
201
202 voiceId = 'JBFqnCBsd6RMkjVDRZzb'
203
204 ): Promise<string> {
205
206 const client = new ElevenLabsClient({ apiKey });
207
208 const audioStream = await client.textToSpeech.convert(voiceId, {
209
210 text,
211
212 model_id: 'eleven_multilingual_v2',
213
214 voice_settings: {
215
216 stability: 0.5,
217
218 similarity_boost: 0.75,
219
220 style: 0.0
221
222 }
223
224 });
225
226 const chunks: Buffer[] = [];
227
228 for await (const chunk of audioStream) {
229
230 chunks.push(Buffer.from(chunk));
231
232 }
233
234 await fs.promises.writeFile(outputPath, Buffer.concat(chunks));
235
236 return outputPath;
237
238 }
239
240 ```
241
242 ---
243
244 ## Voice Selection
245
246 ### Listing Available Voices
247
248 ```typescript
249
250 async function listVoices(apiKey: string) {
251
252 const response = await fetch('https://api.elevenlabs.io/v1/voices', {
253
254 headers: { 'xi-api-key': apiKey }
255
256 });
257
258 const data = await response.json();
259
260 return data.voices.map((v: any) => ({
261
262 id: v.voice_id,
263
264 name: v.name,
265
266 category: v.category,
267
268 labels: v.labels,
269
270 previewUrl: v.preview_url
271
272 }));
273
274 }
275
276 ```
277
278 ### Popular Pre-made Voices
279
280 These are commonly available ElevenLabs voices good for voiceovers:
281
282 | Voice | ID | Best For |
283
284 |-------|-----|----------|
285
286 | George | `JBFqnCBsd6RMkjVDRZzb` | Warm narration, documentaries |
287
288 | Rachel | `21m00Tcm4TlvDq8ikWAM` | Professional, clear narration |
289
290 | Adam | `pNInz6obpgDQGcFmaJgB` | Deep, authoritative narration |
291
292 | Bella | `EXAVITQu4vr4xnSDxMaL` | Friendly, conversational |
293
294 | Antoni | `ErXwobaYiN019PkySvjV` | Calm, measured narration |
295
296 | Domi | `AZnzlk1XvdvUeBnXmlld` | Energetic, youthful |
297
298 When the user doesn't specify a voice, ask what tone they want (professional, warm, energetic, etc.) and suggest an appropriate voice.
299
300 ### Voice Settings Explained
301
302 - **Stability** (0.0–1.0): Higher = more consistent/monotone, Lower = more expressive/varied. Default 0.5 is balanced. Use 0.7+ for narration, 0.3 for dramatic reads.
303 - **Similarity Boost** (0.0–1.0): How closely to match the original voice. 0.75 is a good default. Higher values may introduce artifacts.
304
305 - **Style** (0.0–1.0): Exaggeration of the voice's style. 0.0 is neutral. Higher values amplify the voice's character but can sound unnatural. Keep at 0.0 for voiceovers.
306
307 ---
308
309 ## Mixing Audio into Video
310
311 ### Mode 1: Replace Audio Entirely
312
313 Remove existing audio and use only the voiceover:
314
315 ```bash
316
317 ffmpeg -y -i video.mp4 -i voiceover.mp3 \\
318
319 -map 0:v -map 1:a \\
320
321 -c:v copy -c:a aac -b:a 192k \\
322
323 -shortest output.mp4
324
325 ```
326
327 ```typescript
328
329 import { execFile } from 'child_process';
330
331 import { promisify } from 'util';
332
333 const execFileAsync = promisify(execFile);
334
335 async function replaceAudio(videoPath: string, voiceoverPath: string, outputPath: string) {
336
337 await execFileAsync('ffmpeg', [
338
339 '-y',
340
341 '-i', videoPath,
342
343 '-i', voiceoverPath,
344
345 '-map', '0:v',
346
347 '-map', '1:a',
348
349 '-c:v', 'copy',
350
351 '-c:a', 'aac', '-b:a', '192k',
352
353 '-shortest',
354
355 outputPath
356
357 ]);
358
359 }
360
361 ```
362
363 ### Mode 2: Mix Over (duck existing audio)
364
365 Layer voiceover on top of existing audio, reducing the existing audio volume:
366
367 ```bash
368
369 ffmpeg -y -i video.mp4 -i voiceover.mp3 \\
370
371 -filter_complex "[0:a]volume=0.2[bg];[bg][1:a]amix=inputs=2:duration=first[outa]" \\
372
373 -map 0:v -map "[outa]" \\
374
375 -c:v copy -c:a aac -b:a 192k \\
376
377 output.mp4
378
379 ```
380
381 ```typescript
382
383 async function mixOverAudio(
384
385 videoPath: string,
386
387 voiceoverPath: string,
388
389 outputPath: string,
390
391 bgVolume = 0.2 // How much to reduce existing audio (0.0 = silent, 1.0 = full)
392
393 ) {
394
395 await execFileAsync('ffmpeg', [
396
397 '-y',
398
399 '-i', videoPath,
400
401 '-i', voiceoverPath,
402
403 '-filter_complex',
404
405 `[0:a]volume=${bgVolume}[bg];[bg][1:a]amix=inputs=2:duration=first[outa]`,
406
407 '-map', '0:v',
408
409 '-map', '[outa]',
410
411 '-c:v', 'copy',
412
413 '-c:a', 'aac', '-b:a', '192k',
414
415 outputPath
416
417 ]);
418
419 }
420
421 ```
422
423 ### Mode 3: Add as Track (full volume both)
424
425 Keep existing audio at full volume, add voiceover on top:
426
427 ```bash
428
429 ffmpeg -y -i video.mp4 -i voiceover.mp3 \\
430
431 -filter_complex "[0:a][1:a]amix=inputs=2:duration=first[outa]" \\
432
433 -map 0:v -map "[outa]" \\
434
435 -c:v copy -c:a aac -b:a 192k \\
436
437 output.mp4
438
439 ```
440
441 ```typescript
442
443 async function addAudioTrack(videoPath: string, voiceoverPath: string, outputPath: string) {
444
445 await execFileAsync('ffmpeg', [
446
447 '-y',
448
449 '-i', videoPath,
450
451 '-i', voiceoverPath,
452
453 '-filter_complex',
454
455 '[0:a][1:a]amix=inputs=2:duration=first[outa]',
456
457 '-map', '0:v',
458
459 '-map', '[outa]',
460
461 '-c:v', 'copy',
462
463 '-c:a', 'aac', '-b:a', '192k',
464
465 outputPath
466
467 ]);
468
469 }
470
471 ```
472
473 ### Adding Voiceover to a Video with No Audio
474
475 If the input video has no audio stream:
476
477 ```bash
478
479 ffmpeg -y -i video.mp4 -i voiceover.mp3 \\
480
481 -map 0:v -map 1:a \\
482
483 -c:v copy -c:a aac -b:a 192k \\
484
485 -shortest output.mp4
486
487 ```
488
489 ---
490
491 ## Timed Voiceover Segments
492
493 For videos that need voiceover at specific timestamps (not continuous narration), generate separate audio clips and position them on a timeline.
494
495 ### Step 1: Define the Script with Timestamps
496
497 ```typescript
498
499 interface VoiceoverSegment {
500
501 text: string;
502
503 startTime: number; // seconds
504
505 voiceId?: string; // optional per-segment voice
506
507 }
508
509 const script: VoiceoverSegment[] = [
510
511 { text: "Welcome to our product overview.", startTime: 0 },
512
513 { text: "Here's how it works in three simple steps.", startTime: 5.5 },
514
515 { text: "Step one: upload your content.", startTime: 10 },
516
517 { text: "Step two: customize your settings.", startTime: 18 },
518
519 { text: "Step three: share with the world.", startTime: 25 },
520
521 ];
522
523 ```
524
525 ### Step 2: Generate Each Segment
526
527 ```typescript
528
529 async function generateSegments(
530
531 segments: VoiceoverSegment[],
532
533 tmpDir: string,
534
535 apiKey: string,
536
537 defaultVoiceId: string
538
539 ): Promise<string[]> {
540
541 const paths: string[] = [];
542
543 for (let i = 0; i < segments.length; i++) {
544
545 const outputPath = path.join(tmpDir, `segment_${i}.mp3`);
546
547 await generateVoiceover(
548
549 segments[i].text,
550
551 outputPath,
552
553 apiKey,
554
555 segments[i].voiceId || defaultVoiceId
556
557 );
558
559 paths.push(outputPath);
560
561 }
562
563 return paths;
564
565 }
566
567 ```
568
569 ### Step 3: Create a Combined Audio Track with Positioning
570
571 Use FFmpeg to position each segment at its target timestamp and mix them into a single audio track:
572
573 ```typescript
574
575 async function assembleTimedVoiceover(
576
577 segments: VoiceoverSegment[],
578
579 segmentPaths: string[],
580
581 totalDuration: number,
582
583 outputPath: string
584
585 ) {
586
587 const inputs: string[] = [];
588
589 const filterParts: string[] = [];
590
591 for (let i = 0; i < segmentPaths.length; i++) {
592
593 inputs.push('-i', segmentPaths[i]);
594
595 const delay = Math.round(segments[i].startTime * 1000);
596
597 filterParts.push(`[${i}:a]adelay=${delay}|${delay}[a${i}]`);
598
599 }
600
601 const mixInputs = segments.map((_, i) => `[a${i}]`).join('');
602
603 const filterComplex = [
604
605 ...filterParts,
606
607 `${mixInputs}amix=inputs=${segments.length}:normalize=0[mixed]`,
608
609 `[mixed]apad=whole_dur=${totalDuration}[out]`
610
611 ].join(';');
612
613 await execFileAsync('ffmpeg', [
614
615 '-y',
616
617 ...inputs,
618
619 '-filter_complex', filterComplex,
620
621 '-map', '[out]',
622
623 '-c:a', 'aac', '-b:a', '192k',
624
625 outputPath
626
627 ]);
628
629 }
630
631 ```
632
633 ### Step 4: Mix the Assembled Voiceover into the Video
634
635 Use one of the mixing modes above (replace, mix over, or add as track) with the assembled voiceover file.
636
637 ---
638
639 ## Complete Pipeline Example
640
641 ```typescript
642
643 import * as path from 'path';
644
645 import * as os from 'os';
646
647 async function addVoiceoverToVideo(
648
649 videoPath: string,
650
651 script: string | VoiceoverSegment[],
652
653 apiKey: string,
654
655 options: {
656
657 voiceId?: string;
658
659 mode?: 'replace' | 'mix' | 'add';
660
661 bgVolume?: number;
662
663 } = {}
664
665 ) {
666
667 const {
668
669 voiceId = 'JBFqnCBsd6RMkjVDRZzb',
670
671 mode = 'mix',
672
673 bgVolume = 0.2
674
675 } = options;
676
677 const tmpDir = path.join(os.tmpdir(), `voiceover_${Date.now()}`);
678
679 await fs.promises.mkdir(tmpDir, { recursive: true });
680
681 const outputPath = videoPath.replace(/(\\\w+)$/, '_voiceover$1');
682
683 if (typeof script === 'string') {
684
685 // Simple full-video voiceover
686
687 console.log('Generating voiceover audio...');
688
689 const voiceoverPath = path.join(tmpDir, 'voiceover.mp3');
690
691 await generateVoiceover(script, voiceoverPath, apiKey, voiceId);
692
693 console.log(`Mixing audio (mode: ${mode})...`);
694
695 switch (mode) {
696
697 case 'replace':
698
699 await replaceAudio(videoPath, voiceoverPath, outputPath);
700
701 break;
702
703 case 'mix':
704
705 await mixOverAudio(videoPath, voiceoverPath, outputPath, bgVolume);
706
707 break;
708
709 case 'add':
710
711 await addAudioTrack(videoPath, voiceoverPath, outputPath);
712
713 break;
714
715 }
716
717 } else {
718
719 // Timed segments
720
721 console.log(`Generating ${script.length} voiceover segments...`);
722
723 const segmentPaths = await generateSegments(script, tmpDir, apiKey, voiceId);
724
725 // Get video duration
726
727 const { stdout } = await execFileAsync('ffprobe', [
728
729 '-v', 'quiet', '-print_format', 'json', '-show_format', videoPath
730
731 ]);
732
733 const duration = parseFloat(JSON.parse(stdout).format.duration);
734
735 console.log('Assembling timed voiceover...');
736
737 const assembledPath = path.join(tmpDir, 'assembled_voiceover.aac');
738
739 await assembleTimedVoiceover(script, segmentPaths, duration, assembledPath);
740
741 console.log(`Mixing audio (mode: ${mode})...`);
742
743 switch (mode) {
744
745 case 'replace':
746
747 await replaceAudio(videoPath, assembledPath, outputPath);
748
749 break;
750
751 case 'mix':
752
753 await mixOverAudio(videoPath, assembledPath, outputPath, bgVolume);
754
755 break;
756
757 case 'add':
758
759 await addAudioTrack(videoPath, assembledPath, outputPath);
760
761 break;
762
763 }
764
765 }
766
767 // Clean up
768
769 await fs.promises.rm(tmpDir, { recursive: true, force: true });
770
771 console.log(`Voiceover added: ${outputPath}`);
772
773 return outputPath;
774
775 }
776
777 ```
778
779 ## Tips
780
781 - **Character limits:** ElevenLabs has a per-request character limit (typically 5,000 characters). For long narrations, split the text into chunks and concatenate the resulting audio files.
782 - **Audio format:** ElevenLabs returns MP3 by default. You can request other formats via the `output_format` parameter (e.g., `pcm_16000` for raw PCM, `mp3_44100_128` for specific bitrate).
783
784 - **Pacing:** If the voiceover needs to match specific video timing, adjust the text length per segment rather than trying to speed up/slow down the generated audio. Shorter text = shorter audio.
785 - **Silence padding:** Use `apad` or `adelay` in FFmpeg to position audio precisely. ElevenLabs doesn't add leading silence, so timing is exact.
786
787 - **Cost awareness:** ElevenLabs charges per character. Long narrations can add up. Let the user know the approximate character count before generating.
788 - **Multilingual:** The `eleven_multilingual_v2` model supports 29 languages. The text language is auto-detected. No special configuration needed — just write the text in the target language.