Objective
What you're building
One locked-in voice, used consistently across every video — plus real cost tracking, since ElevenLabs' free tier (Lesson 1's warning) runs out fast enough to actually matter this lesson.
Pick once, use everywhere
import os
import requests
VOICE_ID = "your-chosen-voice-id" # picked once in this lesson, reused every video
def generate_voiceover(script_text: str, output_path: str) -> dict:
response = requests.post(
f"https://api.elevenlabs.io/v1/text-to-speech/{VOICE_ID}",
headers={"xi-api-key": os.environ["ELEVENLABS_API_KEY"], "Content-Type": "application/json"},
json={
"text": script_text,
"voice_settings": {"stability": 0.5, "similarity_boost": 0.75},
},
)
with open(output_path, "wb") as f:
f.write(response.content)
return {"output_path": output_path, "characters_used": len(script_text)}
VOICE_ID is a module-level constant, not something re-chosen per video -- this is the deliberate design decision this lesson opens with: a viewer who watches 5 videos from your channel should hear the SAME voice every time, which is what makes it feel like a channel and not 5 unrelated clips.
Cost tracking, since the free tier is real and small
10,000 free characters/month disappears in 3-4 scripts -- track it, don't guess
A typical 60-90 second script is 150-220 words, roughly 900-1300 characters. At that rate, ElevenLabs' free tier covers maybe 7-10 test generations before you hit the wall mid-course. Track cumulative characters used in a simple log file so you know exactly when you're approaching the limit, rather than discovering it via a failed API call mid-pipeline-run.
def log_character_usage(characters_used: int, log_path: str = "character_usage.json"):
total = 0
if os.path.exists(log_path):
with open(log_path) as f:
total = json.load(f).get("total_characters", 0)
total += characters_used
with open(log_path, "w") as f:
json.dump({"total_characters": total, "free_tier_limit": 10000}, f)
if total > 8000:
print(f"WARNING: {total}/10000 free characters used this month -- consider upgrading")
This is a small, cheap addition that prevents exactly the "ran out mid-course" scenario Lesson 1 warned about — a warning at 80% used gives you time to decide (upgrade, or budget remaining tests carefully) instead of hitting a hard stop with no notice.
You generate voiceovers for 3 different scripts and notice video 2's voiceover sounds noticeably quieter than videos 1 and 3, even though you used the same voice_id and settings for all 3. What's the most likely explanation?
TTS generation, even from the same voice and settings, can produce slightly different output volume run to run — this is a known, real characteristic, not a bug specific to your setup. The fix is a normalization step (adjusting each output to a consistent target loudness, e.g. with a library like pydub or an FFmpeg audio filter) applied to every voiceover before it moves downstream, so viewers don't experience jarring volume differences between videos.
What you're building today
voiceover_generator.py: generate_voiceover (locked voice, consistent settings) with log_character_usage tracking cumulative cost, and a normalization pass — tested on 3 scripts and verified all 3 sound consistent in voice AND volume when played back to back.