How to Add a Synced Voiceover to Any Video
(Free, No Editor Required)
You've got a video with no narration and a script you want timed to specific moments on screen. Here's the whole path from nothing to a finished, muxed file โ one terminal, zero paid tools.
Each step below is short on purpose, so the overall shape of the job stays easy to follow start to finish. If a step has a More info toggle under it, that's where the deeper explanation lives โ why the command is shaped the way it is, what the flags actually do, and the couple of gotchas that'll cost you real time if you don't know they exist. You don't need to open any of them to finish the tutorial. Open them if you want to actually understand the tools instead of just running them.
What you need
- A video file with no narration.
- A rough idea, in seconds, of when each thing you want to say should start.
- Python 3 and ffmpeg installed.
python3 --version ffmpeg
Step 1 โ Set up an isolated environment
More detailed info below: why a venv, and why edge-tts
$ python3 -m venv .venv $ .venv/bin/pip install --upgrade pip edge-tts pydub
More detailed infoLess info
Why not just pip install edge-tts pydub? On current Debian/Ubuntu (and increasingly elsewhere), the system Python is "externally managed" โ pip will refuse a global install outright:
error: externally-managed-environment ร This environment is externally managed`
This isn't a bug, it's PEP 668: distro-packaged Python has OS tooling depending on specific package versions, and an unrelated pip install at the system level can quietly break that. The fix isn't the --break-system-packages flag some tutorials suggest โ that just turns the warning off, it doesn't remove the actual risk. A virtual environment sidesteps the problem entirely by giving the project its own private copy of Python plus packages, isolated from the system in both directions: nothing you install can affect anything outside .venv, and nothing outside it can break what's inside. Any command below that starts with .venv/bin/... is running inside that sandbox.
Why edge-tts and not the more commonly suggested gTTS? gTTS is a thin wrapper around Google Translate's "read this aloud" button. It was never built to narrate anything โ it has one flat robotic voice per language and no real options. edge-tts calls the same neural voice engine behind Microsoft Edge's "Read Aloud" feature: full sentences with actual intonation, dozens of voice choices (en-US-GuyNeural, en-US-AriaNeural, en-US-ChristopherNeural, and equivalents in other languages), still completely free. Worth knowing: it does this by talking to the same backend the Edge browser itself uses internally โ it's not an officially published API for third-party use, so treat it as a great free tool, not a guaranteed-forever one. If it ever breaks, Azure's official Cognitive Services Speech API uses the same voice models as a paid, supported fallback.
Step 2 โ Write your script as timestamped segments
Watch your own video and note, in seconds, when each visual beat happens โ when a preview starts playing, when a button gets clicked, when a result appears.
$ segments = [{"start": 0.0, "text": "Ever had a video where the audio pitch just isn't right?..."}, {"start": 25.0, "text": "First, let's hear the original audio, completely unchanged."}, {"start": 40.0, "text": "Now, set your pitch. Drag the slider down..."}, {"start": 72.0, "text": "Now listen to that same clip, pitched lower."}, {"start": 96.0, "text": "Happy with it? Download your new file..."}, {"start": 115.0, "text": "Best of all, this tool is open source..."},]More detailed infoLess info
More info: getting the timestamps right the first time Seconds, not minute:second โ converting 1:41 to 101.0 now saves you doing arithmetic in your head later, and it's the unit every tool downstream (pydub, ffmpeg) actually wants.
Don't eyeball this by scrubbing a video player back and forth โ it's slow and your thumb isn't that precise. A more reliable way to review an entire clip's timeline at once: pull a frame out every couple of seconds with the timestamp burned directly into the image, then tile a bunch of them into a single contact-sheet image.
ffmpeg -i source.mp4 \
-vf "fps=1/2,scale=480:-1,drawtext=text='%{pts\:hms}':x=10:y=10:fontsize=24:fontcolor=yellow:box=1:boxcolor=black@0.6" \
frame_%03d.jpg
ffmpeg -i frame_%03d.jpg -vf "tile=4x3" sheet_%02d.jpgfps=1/2 means "one frame every 2 seconds" (it's a rate, so 1/2 = 0.5 fps). drawtext with %{pts\:hms} stamps ffmpeg's own internal timestamp for that frame directly onto the image in hours:minutes:seconds โ that's the actual video time, not a guess. tile=4x3 then packs 12 of those frames into one image, so a 2-minute video turns into a handful of images you can flip through in seconds instead of a video you have to scrub.
This is exactly how a 5-second timing error gets caught before you've generated any audio: open the sheets, find the frame where the thing you're narrating actually happens, read the timestamp off it, done. Cheaper to fix a text file now than to regenerate audio later.
Keep each line short enough to finish comfortably before the next beat needs to start. If a line's too long for its window, that's a rewrite โ trying to out-engineer a script that doesn't fit its own time budget is a losing game no tool will solve for you.
Step 3 โ Generate the audio
Click to see the code:
More detailed infoLess info
import asyncio
import os
import edge_tts
from pydub import AudioSegment
VOICE = "en-US-GuyNeural"
segments = [
# ... your segments from Step 2 ...
]
async def generate_segment(text: str, filename: str, voice: str = VOICE) -> None:
communicate = edge_tts.Communicate(text, voice)
await communicate.save(filename)
async def main() -> None:
combined = AudioSegment.silent(duration=0)
for i, seg in enumerate(segments):
target_start_ms = int(seg["start"] * 1000)
padding_ms = target_start_ms - len(combined)
if padding_ms > 0:
combined += AudioSegment.silent(duration=padding_ms)
temp_filename = f"temp_{i}.mp3"
await generate_segment(seg["text"], temp_filename)
combined += AudioSegment.from_mp3(temp_filename)
os.remove(temp_filename)
combined.export("full_voiceover.mp3", format="mp3")
print("Voiceover generated: full_voiceover.mp3")
if __name__ == "__main__":
asyncio.run(main()).venv/bin/python generate_voiceover.py
what this script is actually doing The core trick is a running tally: combined starts as zero seconds of silence, and for every segment, the script checks how much padding is needed to stretch combined from its current length up to that segment's target start time, adds exactly that much silence, then appends the spoken clip. Because it's a running total rather than fixed gaps, a segment that renders slightly longer than expected doesn't break anything โ it just means the next padding calculation naturally comes out smaller. That's why this approach is more robust than hard-coding gap lengths between segments.
edge_tts.Communicate is asynchronous because under the hood it's opening a WebSocket connection to fetch streamed audio chunks rather than doing a single blocking HTTP request โ that's why the script needs asyncio.run(main()) at the bottom rather than calling everything directly. You don't need to understand WebSockets to use this; you just need to know that every call to generate_segment needs await in front of it, and the function that calls it needs to be declared async def too, all the way up the chain.
One thing worth checking once you're done:
ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1 full_voiceover.mp3
It should land a few seconds shorter than your source video. That's expected โ it just means the last line finishes with breathing room before the video ends, not that something got cut off.
Step 4 โ Check the sync objectively, not by ear
Compare where each long silent gap ends against your Step 2 targets. Small drift (under half a second) is normal and won't be noticeable; anything bigger is worth a look.
$ ffmpeg -i full_voiceover.mp3 -af silencedetect=noise=-35dB:d=0.3 -f null - 2>&1 | grep silence_More detailed infoLess info
More info: reading silencedetect output, and a logging gotcha that costs people real time silencedetect=noise=-35dB:d=0.3 means: treat anything quieter than -35dB as silence, but only report it if it lasts at least 0.3 seconds (so normal micro-pauses between words don't spam the output). The filter doesn't change the audio at all โ piping the output to -f null - just means "decode this and throw the result away, I only want the log messages," which is a handy pattern any time you want ffmpeg to analyze a file without producing a new one.
Reading the output: every segment of your generated track will show up as one silence_start / silence_end pair. The long gaps (several seconds) are the padding you inserted between segments โ the moment one of those ends is the moment the next line of narration actually starts speaking. The short gaps (under a second, scattered inside a single silence block) are just natural pauses between sentences within one segment โ ignore those, they're not segment boundaries.
Here's the gotcha, and it'll bite you the first time you try to run this same kind of check on a file that already has other audio mixed in (like the final muxed video, later): ffmpeg's analysis filters โ silencedetect, volumedetect, astats โ all log their findings at the default verbosity level, not as errors. If you've gotten in the habit of adding -v error to keep ffmpeg's usual noisy output quiet, that flag will also swallow the one line you actually wanted to read, and the command will appear to "just work" with zero output. If you ever see an analysis filter produce nothing at all, check whether you've suppressed the log level before assuming the filter failed.
Step 5 โ Mux the voiceover onto the video
See "More Info" to see why mix instead of replace, and what every flag here is doing
$ ffmpeg -i source.mp4 -i full_voiceover.mp3 \ $ -filter_complex "[0:a][1:a]amix=inputs=2:duration=longest:normalize=0[aout]" \ $ -map 0:v:0 -map "[aout]" \ $ -c:v copy -c:a aac -b:a 192k \ $ final.mp4
More detailed infoLess info
If your script has any "listen to this" moments, replacing the video's original audio outright leaves nothing for the viewer to actually hear at exactly the moment the narration tells them to listen. The fix here relies on something you already set up in Step 2: because the narration script stays silent during those "let the original audio play" windows, the two tracks never actually compete for the same moment, so a straight mix works cleanly with no manual volume automation needed.
Flag by flag:
-filter_complex "[0:a][1:a]amix=inputs=2:..."takes the audio stream from input 0 (the video) and input 1 (the voiceover) and combines them into one new stream, labeled[aout].duration=longestmeans the combined output runs as long as the longer of the two inputs โ here, the source video โ rather than cutting off early when the shorter voiceover track ends. Useduration=firstif you specifically want it to match input 0's length regardless of which is longer.normalize=0turns offamix's default behavior of automatically scaling down every input's volume so the sum doesn't clip (its default assumption is "these are all similar in loudness, so divide everyone by N"). With normalization on, both the original audio and the narration would come out at half volume. It's safe to disable here specifically because the two tracks are barely ever loud at the same instant โ narration is silent when original audio needs to be heard, and vice versa.-map 0:v:0 -map "[aout]"tells ffmpeg exactly which streams go into the output: the original video stream untouched, and the newly mixed audio stream in place of the original audio-only stream.-c:v copyis the one that matters most for quality: it tells ffmpeg to copy the video stream's compressed bytes directly into the new file without decoding and re-encoding them. Re-encoding is where video quality actually gets lost (and where most of the processing time goes) โ skipping it entirely means the video comes out bit-for-bit identical to the source. Only the audio, which we're deliberately changing, gets encoded (-c:a aac -b:a 192k).
Step 6 โ Verify, then clean up
More info: why this check earns the right to delete anything
$ ffmpeg -v error -i final.mp4 -f null -More detailed infoLess info
No output means the whole file โ every video frame and every audio sample โ decoded without a single complaint.
rm source.mp4 full_voiceover.mp3 rm -rf .venv # only if you're done iterating
-f null - is the same "analyze, don't produce a file" pattern from Step 4 โ here it's just decoding the entire file front-to-back instead of running a specific analysis filter over it. A corrupt or incomplete mux will typically throw a decode error somewhere in that pass; a clean file will produce silence, because there's nothing to report. This isn't a full guarantee of correctness (it can't tell you the content is right โ that's what Steps 4 and the loudness checks were for), but it does confirm the container and streams themselves aren't damaged, which is exactly the failure mode that would make deleting your only remaining source files a genuinely bad afternoon.
If you want one more level of confidence before deleting anything irreplaceable, a loudness check directly on the final file โ comparing audio levels a fraction of a second before and after a known narration cue โ will show a clear jump from near-silence to speech if the mux landed where it should:
ffmpeg -ss 25.0 -t 0.8 -i final.mp4 -map 0:a -af volumedetect -f null -
(Remember the Step 4 gotcha: don't add -v error here, or you won't see the mean_volume line this command exists to show you.)
That's the whole pipeline: script, generate, verify, mix, verify again, clean up. Nothing here needs a paid tool โ it's a Python script and a handful of ffmpeg invocations you can copy, adapt, and rerun on the next video.