267 lines
9.2 KiB
Python
267 lines
9.2 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Generate timed voiceover for promo videos using ElevenLabs API.
|
|
|
|
Usage:
|
|
1. Set ELEVEN_LABS_API_KEY environment variable
|
|
2. Edit the `sections` list below with your script
|
|
3. Run: python generate_voiceover.py
|
|
|
|
Requirements:
|
|
- Python 3.x
|
|
- ffmpeg installed
|
|
- ELEVEN_LABS_API_KEY environment variable
|
|
"""
|
|
|
|
import urllib.request
|
|
import json
|
|
import subprocess
|
|
import os
|
|
import sys
|
|
|
|
# Configuration
|
|
API_KEY = os.environ.get("ELEVEN_LABS_API_KEY")
|
|
VOICE_ID = "XrExE9yKIg1WjnnlVkGX" # Matilda - Professional, American
|
|
VIDEO_DURATION = 60 # seconds
|
|
OUTPUT_FILE = "voiceover.mp3"
|
|
|
|
# Voice settings for clear, professional delivery
|
|
VOICE_SETTINGS = {
|
|
"stability": 0.65, # Higher = less breathy
|
|
"similarity_boost": 0.85,
|
|
"style": 0.2,
|
|
"use_speaker_boost": True
|
|
}
|
|
|
|
# Define your script sections here
|
|
# Format: (start_time_seconds, "text to speak")
|
|
sections = [
|
|
(0, "Every missed call costs you money."),
|
|
(5, "Your service drive is overwhelmed."),
|
|
(9, "Four hours to call back? That's too late."),
|
|
(13, "Customers won't wait."),
|
|
(17, "Introducing Your Product. Turn problems into solutions."),
|
|
(21, "Feature one described here, so you get the benefit."),
|
|
(26, "Feature two described here, so you get the benefit."),
|
|
(31, "Feature three described here, so you get the benefit."),
|
|
(36, "Feature four described here, so you get the benefit."),
|
|
(41, "Feature five described here, so you get the benefit."),
|
|
(46, "The results speak for themselves. Better outcomes. Real results."),
|
|
(52, "Your Product. Your tagline. Request your demo today."),
|
|
]
|
|
|
|
|
|
def check_requirements():
|
|
"""Verify API key and ffmpeg are available."""
|
|
if not API_KEY:
|
|
print("Error: ELEVEN_LABS_API_KEY environment variable not set")
|
|
print("Set it with: export ELEVEN_LABS_API_KEY=your_key_here")
|
|
sys.exit(1)
|
|
|
|
try:
|
|
subprocess.run(["ffmpeg", "-version"], capture_output=True, check=True)
|
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
print("Error: ffmpeg not found. Install it with: brew install ffmpeg")
|
|
sys.exit(1)
|
|
|
|
|
|
def generate_audio(text: str, filename: str) -> float:
|
|
"""Generate audio for a single text section using ElevenLabs API."""
|
|
url = f"https://api.elevenlabs.io/v1/text-to-speech/{VOICE_ID}"
|
|
data = {
|
|
"text": text,
|
|
"model_id": "eleven_multilingual_v2",
|
|
"voice_settings": VOICE_SETTINGS
|
|
}
|
|
headers = {
|
|
"xi-api-key": API_KEY,
|
|
"Content-Type": "application/json"
|
|
}
|
|
|
|
req = urllib.request.Request(
|
|
url,
|
|
data=json.dumps(data).encode('utf-8'),
|
|
headers=headers,
|
|
method='POST'
|
|
)
|
|
|
|
with urllib.request.urlopen(req, timeout=60) as response:
|
|
with open(filename, "wb") as f:
|
|
f.write(response.read())
|
|
|
|
# Get duration using ffprobe
|
|
result = subprocess.run(
|
|
["ffprobe", "-v", "quiet", "-show_entries", "format=duration", "-of", "csv=p=0", filename],
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
duration = float(result.stdout.strip())
|
|
return duration
|
|
|
|
|
|
def create_silence_base(duration: int, filename: str):
|
|
"""Create a silent audio file as the base track."""
|
|
subprocess.run([
|
|
"ffmpeg", "-y", "-f", "lavfi", "-i", f"anullsrc=r=44100:cl=mono",
|
|
"-t", str(duration), "-q:a", "9", "-acodec", "libmp3lame", filename
|
|
], capture_output=True)
|
|
|
|
|
|
def combine_audio_sections(section_files: list, start_times: list, output: str):
|
|
"""Combine all audio sections with proper timing into final voiceover."""
|
|
inputs = ["-i", "silence_base.mp3"]
|
|
filter_parts = []
|
|
|
|
for i, (filename, start_time) in enumerate(zip(section_files, start_times)):
|
|
inputs.extend(["-i", filename])
|
|
delay_ms = int(start_time * 1000)
|
|
filter_parts.append(f"[{i+1}]adelay={delay_ms}|{delay_ms}[d{i}]")
|
|
|
|
mix_inputs = "[0]" + "".join(f"[d{i}]" for i in range(len(section_files)))
|
|
filter_str = ";".join(filter_parts) + f";{mix_inputs}amix=inputs={len(section_files)+1}:duration=first"
|
|
|
|
cmd = ["ffmpeg", "-y"] + inputs + ["-filter_complex", filter_str, output]
|
|
subprocess.run(cmd, capture_output=True)
|
|
|
|
|
|
def normalize_audio(input_file: str, output_file: str):
|
|
"""Normalize audio levels for consistent volume."""
|
|
subprocess.run([
|
|
"ffmpeg", "-y", "-i", input_file,
|
|
"-af", "loudnorm=I=-16:TP=-1.5:LRA=11",
|
|
output_file
|
|
], capture_output=True)
|
|
|
|
|
|
def cleanup(files: list):
|
|
"""Remove temporary files."""
|
|
for f in files:
|
|
try:
|
|
os.remove(f)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def verify_timing_with_whisper(audio_file: str) -> list:
|
|
"""Use Whisper to transcribe and get actual timestamps."""
|
|
try:
|
|
import whisper
|
|
print("Transcribing with Whisper to verify timing...")
|
|
model = whisper.load_model("tiny")
|
|
result = model.transcribe(audio_file)
|
|
return result.get("segments", [])
|
|
except ImportError:
|
|
print(" Whisper not installed. Install with: pip install openai-whisper")
|
|
print(" Skipping timing verification.")
|
|
return []
|
|
except Exception as e:
|
|
print(f" Whisper error: {e}")
|
|
return []
|
|
|
|
|
|
def check_timing_alignment(segments: list, sections: list) -> list:
|
|
"""Compare Whisper timestamps against expected scene timing."""
|
|
issues = []
|
|
|
|
# Build expected timing windows
|
|
# Each section should start at its defined time and end before the next section
|
|
for i, (start_time, text) in enumerate(sections):
|
|
next_start = sections[i + 1][0] if i + 1 < len(sections) else VIDEO_DURATION
|
|
|
|
# Find matching Whisper segment
|
|
for seg in segments:
|
|
if text[:20].lower() in seg.get("text", "").lower():
|
|
actual_start = seg.get("start", 0)
|
|
actual_end = seg.get("end", 0)
|
|
|
|
if actual_start < start_time - 0.5:
|
|
issues.append(f"Section {i} starts too early: expected {start_time}s, actual {actual_start:.1f}s")
|
|
if actual_end > next_start:
|
|
issues.append(f"Section {i} overlaps into next scene: ends at {actual_end:.1f}s, next scene at {next_start}s")
|
|
break
|
|
|
|
return issues
|
|
|
|
|
|
def main():
|
|
check_requirements()
|
|
|
|
print(f"Generating voiceover with {len(sections)} sections...")
|
|
print(f"Voice: Matilda (ID: {VOICE_ID})")
|
|
print()
|
|
|
|
section_files = []
|
|
start_times = []
|
|
temp_files = ["silence_base.mp3"]
|
|
|
|
# Generate each section
|
|
for i, (start_time, text) in enumerate(sections):
|
|
filename = f"section_{i:02d}.mp3"
|
|
section_files.append(filename)
|
|
start_times.append(start_time)
|
|
temp_files.append(filename)
|
|
|
|
print(f" [{start_time:2d}s] Generating: {text[:50]}...")
|
|
duration = generate_audio(text, filename)
|
|
print(f" -> {duration:.1f}s audio")
|
|
|
|
# Check if this section will overlap with next
|
|
if i + 1 < len(sections):
|
|
next_start = sections[i + 1][0]
|
|
if start_time + duration > next_start:
|
|
print(f" WARNING: May overlap with next section (ends at {start_time + duration:.1f}s, next at {next_start}s)")
|
|
|
|
print()
|
|
print("Creating silence base track...")
|
|
create_silence_base(VIDEO_DURATION, "silence_base.mp3")
|
|
|
|
print("Combining sections...")
|
|
combine_audio_sections(section_files, start_times, "voiceover_raw.mp3")
|
|
temp_files.append("voiceover_raw.mp3")
|
|
|
|
print("Normalizing audio levels...")
|
|
normalize_audio("voiceover_raw.mp3", OUTPUT_FILE)
|
|
|
|
# Verify timing with Whisper
|
|
print()
|
|
segments = verify_timing_with_whisper(OUTPUT_FILE)
|
|
if segments:
|
|
print("\nWhisper transcription timestamps:")
|
|
for seg in segments:
|
|
print(f" {seg['start']:5.1f}s - {seg['end']:5.1f}s: {seg['text'][:60]}")
|
|
|
|
issues = check_timing_alignment(segments, sections)
|
|
if issues:
|
|
print("\n" + "="*60)
|
|
print("OVERLAPS DETECTED - MUST FIX BEFORE PROCEEDING")
|
|
print("="*60)
|
|
for issue in issues:
|
|
print(f" - {issue}")
|
|
print("\nFIX OPTIONS:")
|
|
print(" 1. Shorten the overlapping text (make it punchier)")
|
|
print(" 2. Increase the next section's start time")
|
|
print(" 3. Add '...' to create natural pauses")
|
|
print("\nThen regenerate and verify again. Do NOT proceed with overlaps.")
|
|
print("="*60)
|
|
else:
|
|
print("\nTIMING VERIFIED - No overlaps detected")
|
|
|
|
print("Cleaning up temporary files...")
|
|
cleanup(temp_files)
|
|
|
|
print()
|
|
print(f"Success! Saved to: {OUTPUT_FILE}")
|
|
print()
|
|
print("Next steps:")
|
|
print(" 1. If timing issues were detected, adjust sections[] and run again")
|
|
print(" 2. Add background music:")
|
|
print(f" ffmpeg -y -i {OUTPUT_FILE} -i music.mp3 \\")
|
|
print(' -filter_complex "[1:a]volume=0.15,afade=t=in:st=0:d=2,afade=t=out:st=57:d=3[music];[0:a][music]amix=inputs=2:duration=first" \\')
|
|
print(" voiceover-with-music.mp3")
|
|
print(" 3. Combine with video:")
|
|
print(" ffmpeg -y -i video.mp4 -i voiceover-with-music.mp3 -c:v copy -map 0:v:0 -map 1:a:0 final.mp4")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|