feat: completed with all features
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
# Video Generator Voiceover
|
||||
|
||||
Small helper scripts for creating timed tutorial voiceovers from `.srt` files and replacing a video's original audio with the generated voiceover.
|
||||
|
||||
## Files
|
||||
|
||||
- `scripts/make_voiceover_macos.py` — creates timed WAV/optional MP3 voiceover on macOS
|
||||
- `scripts/make_voiceover_windows.py` — creates timed WAV/optional MP3 voiceover on Windows
|
||||
- `scripts/combine_video_audio.py` — replaces video audio and exports final MP4
|
||||
- `subtitles/` — optional place to keep SRT files
|
||||
- `output/` — optional place to save generated audio/video files
|
||||
|
||||
## Install
|
||||
|
||||
### Windows
|
||||
|
||||
```powershell
|
||||
winget install Gyan.FFmpeg
|
||||
```
|
||||
|
||||
Install Python from `python.org` or Microsoft Store, then reopen PowerShell and check:
|
||||
|
||||
```powershell
|
||||
ffmpeg -version
|
||||
python --version
|
||||
```
|
||||
|
||||
### macOS
|
||||
|
||||
```bash
|
||||
brew install ffmpeg
|
||||
```
|
||||
|
||||
Python is already available on most Macs. If needed, install it from `python.org`.
|
||||
|
||||
## Create voiceover
|
||||
|
||||
Run from the `video-generator` folder. Pass your own SRT and output paths.
|
||||
|
||||
### Windows
|
||||
|
||||
```powershell
|
||||
python scripts/make_voiceover_windows.py `
|
||||
--srt "video-generator/subtitles/your_subtitles.srt" `
|
||||
--voice "Microsoft David Desktop" `
|
||||
--rate 0 `
|
||||
--wav "video-generator/output/voiceover.wav" `
|
||||
--mp3 "video-generator/output/voiceover.mp3"
|
||||
```
|
||||
|
||||
### macOS
|
||||
|
||||
```bash
|
||||
python video-generator/scripts/make_voiceover_macos.py \
|
||||
--srt "video-generator/subtitles/your_subtitles.srt" \
|
||||
--voice "Daniel" \
|
||||
--rate 185 \
|
||||
--wav "video-generator/output/voiceover.wav" \
|
||||
--mp3 "video-generator/output/voiceover.mp3"
|
||||
```
|
||||
|
||||
`--mp3` is optional. Use WAV when combining with video.
|
||||
|
||||
## Combine video + audio
|
||||
|
||||
This removes the original video audio and uses the generated voiceover.
|
||||
|
||||
### Windows
|
||||
|
||||
```powershell
|
||||
python video-generator/scripts/combine_video_audio.py `
|
||||
--video "video-generator/input/your_video.mp4" `
|
||||
--audio "video-generator/output/voiceover.wav" `
|
||||
--output "video-generator/output/final_video.mp4"
|
||||
```
|
||||
|
||||
### macOS
|
||||
|
||||
```bash
|
||||
python video-generator/scripts/combine_video_audio.py \
|
||||
--video "video-generator/input/your_video.mp4" \
|
||||
--audio "video-generator/output/voiceover.wav" \
|
||||
--output "video-generator/output/final_video.mp4"
|
||||
```
|
||||
|
||||
Use absolute paths if files are outside this folder.
|
||||
|
||||
## Voice and speed options
|
||||
|
||||
### Windows voice list
|
||||
|
||||
```powershell
|
||||
Add-Type -AssemblyName System.Speech
|
||||
$synth = New-Object System.Speech.Synthesis.SpeechSynthesizer
|
||||
$synth.GetInstalledVoices() | ForEach-Object { $_.VoiceInfo.Name }
|
||||
```
|
||||
|
||||
Good tutorial voices: `Microsoft David Desktop`, `Microsoft Mark` if available.
|
||||
|
||||
Windows speed uses `-10` to `10`:
|
||||
|
||||
```powershell
|
||||
python video-generator/scripts/make_voiceover_windows.py `
|
||||
--srt "video-generator/subtitles/your_subtitles.srt" `
|
||||
--wav "video-generator/output/voiceover.wav" `
|
||||
--voice "Microsoft David Desktop" `
|
||||
--rate 2
|
||||
```
|
||||
|
||||
To add voices: `Settings > Time & language > Speech > Manage voices > Add voices`.
|
||||
|
||||
### macOS voice list
|
||||
|
||||
```bash
|
||||
say -v '?'
|
||||
```
|
||||
|
||||
Good tutorial voices: `Daniel`, `Reed (English (US))`, `Eddy (English (UK))`.
|
||||
|
||||
Speed example:
|
||||
|
||||
```bash
|
||||
python video-generator/scripts/make_voiceover_macos.py \
|
||||
--srt "video-generator/subtitles/your_subtitles.srt" \
|
||||
--wav "video-generator/output/voiceover.wav" \
|
||||
--voice "Daniel" \
|
||||
--rate 190
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
If a caption has too much text for its time slot, the script keeps the SRT timing and trims the speech. Fix that by increasing the subtitle duration, shortening the sentence, or increasing speed.
|
||||
|
||||
If the final MP4 has compatibility issues, add `--reencode-video` to the combine command.
|
||||
@@ -0,0 +1,59 @@
|
||||
import argparse
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def resolve_path(value: str) -> Path:
|
||||
return Path(value).expanduser().resolve()
|
||||
|
||||
|
||||
def run(command: list[str]) -> None:
|
||||
print("Running:", " ".join(command))
|
||||
subprocess.run(command, check=True)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Replace a video's original audio with a new voiceover audio file."
|
||||
)
|
||||
parser.add_argument("--video", required=True, help="Input video file, for example input/tutorial.mp4")
|
||||
parser.add_argument("--audio", required=True, help="Input voiceover audio file, for example output/voiceover.wav")
|
||||
parser.add_argument("--output", required=True, help="Output MP4 file, for example output/final_video.mp4")
|
||||
parser.add_argument(
|
||||
"--reencode-video",
|
||||
action="store_true",
|
||||
help="Re-encode video with H.264 instead of copying. Use only if the copied output has playback issues.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
video_file = resolve_path(args.video)
|
||||
audio_file = resolve_path(args.audio)
|
||||
output_file = resolve_path(args.output)
|
||||
|
||||
if not video_file.exists():
|
||||
raise FileNotFoundError(f"Video not found: {video_file}")
|
||||
if not audio_file.exists():
|
||||
raise FileNotFoundError(f"Audio not found: {audio_file}")
|
||||
|
||||
output_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
video_codec_args = ["-c:v", "libx264", "-preset", "medium", "-crf", "18"] if args.reencode_video else ["-c:v", "copy"]
|
||||
|
||||
command = [
|
||||
"ffmpeg", "-y",
|
||||
"-i", str(video_file),
|
||||
"-i", str(audio_file),
|
||||
"-map", "0:v:0", # video from first input
|
||||
"-map", "1:a:0", # audio from second input only, so original video audio is muted/removed
|
||||
*video_codec_args,
|
||||
"-c:a", "aac",
|
||||
"-b:a", "192k",
|
||||
"-movflags", "+faststart",
|
||||
str(output_file),
|
||||
]
|
||||
run(command)
|
||||
print(f"Done: {output_file}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,107 @@
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
SRT_BLOCK_RE = re.compile(
|
||||
r"\s*(\d+)\s+([\d:,]+)\s+-->\s+([\d:,]+)\s+(.+?)(?=\n\s*\d+\s+\d\d:|\Z)",
|
||||
re.S,
|
||||
)
|
||||
|
||||
|
||||
def to_seconds(timestamp: str) -> float:
|
||||
h, m, rest = timestamp.split(":")
|
||||
s, ms = rest.split(",")
|
||||
return int(h) * 3600 + int(m) * 60 + int(s) + int(ms) / 1000
|
||||
|
||||
|
||||
def read_srt(path: Path) -> list[tuple[float, float, str]]:
|
||||
text = path.read_text(encoding="utf-8-sig")
|
||||
captions = []
|
||||
for _, start, end, body in SRT_BLOCK_RE.findall(text):
|
||||
clean_text = " ".join(line.strip() for line in body.strip().splitlines())
|
||||
captions.append((to_seconds(start), to_seconds(end), clean_text))
|
||||
if not captions:
|
||||
raise ValueError(f"No captions found in {path}")
|
||||
return captions
|
||||
|
||||
|
||||
def resolve_path(value: str) -> Path:
|
||||
return Path(value).expanduser().resolve()
|
||||
|
||||
|
||||
def media_duration(path: Path) -> float:
|
||||
result = subprocess.run(
|
||||
["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", str(path)],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return float(result.stdout.strip())
|
||||
|
||||
|
||||
def run(command: list[str]) -> None:
|
||||
print("Running:", " ".join(command))
|
||||
subprocess.run(command, check=True)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Create a timed voiceover WAV/MP3 from an SRT file on macOS.")
|
||||
parser.add_argument("--srt", required=True, help="Input SRT file")
|
||||
parser.add_argument("--voice", default="Daniel", help="macOS voice name, e.g. Daniel or Reed (English (US))")
|
||||
parser.add_argument("--rate", type=int, default=185, help="Speech rate. Higher is faster. Example: 180-200")
|
||||
parser.add_argument("--wav", required=True, help="Output WAV file")
|
||||
parser.add_argument("--mp3", default=None, help="Optional output MP3 file")
|
||||
args = parser.parse_args()
|
||||
|
||||
srt_path = resolve_path(args.srt)
|
||||
wav_output = resolve_path(args.wav)
|
||||
mp3_output = resolve_path(args.mp3) if args.mp3 else None
|
||||
work_dir = Path(__file__).resolve().parent / "voice_parts"
|
||||
|
||||
if not srt_path.exists():
|
||||
raise FileNotFoundError(f"SRT not found: {srt_path}")
|
||||
|
||||
work_dir.mkdir(parents=True, exist_ok=True)
|
||||
wav_output.parent.mkdir(parents=True, exist_ok=True)
|
||||
if mp3_output:
|
||||
mp3_output.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
captions = read_srt(srt_path)
|
||||
segment_files = []
|
||||
|
||||
for i, (start, end, text) in enumerate(captions, 1):
|
||||
duration = end - start
|
||||
raw_aiff = work_dir / f"raw_{i:03d}.aiff"
|
||||
segment_wav = work_dir / f"seg_{i:03d}.wav"
|
||||
|
||||
run(["say", "-v", args.voice, "-r", str(args.rate), "-o", str(raw_aiff), text])
|
||||
|
||||
spoken_duration = media_duration(raw_aiff)
|
||||
if spoken_duration > duration:
|
||||
print(f"Warning: caption {i} speech is {spoken_duration:.2f}s but slot is {duration:.2f}s; it will be trimmed.")
|
||||
|
||||
run([
|
||||
"ffmpeg", "-y",
|
||||
"-i", str(raw_aiff),
|
||||
"-af", f"apad,atrim=0:{duration}",
|
||||
"-ar", "44100",
|
||||
"-ac", "2",
|
||||
str(segment_wav),
|
||||
])
|
||||
segment_files.append(segment_wav)
|
||||
|
||||
concat_list = work_dir / "list.txt"
|
||||
concat_list.write_text("".join(f"file '{os.path.abspath(file)}'\n" for file in segment_files), encoding="utf-8")
|
||||
|
||||
run(["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(concat_list), "-c:a", "pcm_s16le", str(wav_output)])
|
||||
print(f"Done: {wav_output}")
|
||||
|
||||
if mp3_output:
|
||||
run(["ffmpeg", "-y", "-i", str(wav_output), "-codec:a", "libmp3lame", "-b:a", "192k", str(mp3_output)])
|
||||
print(f"Done: {mp3_output}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,130 @@
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
SRT_BLOCK_RE = re.compile(
|
||||
r"\s*(\d+)\s+([\d:,]+)\s+-->\s+([\d:,]+)\s+(.+?)(?=\n\s*\d+\s+\d\d:|\Z)",
|
||||
re.S,
|
||||
)
|
||||
|
||||
|
||||
def to_seconds(timestamp: str) -> float:
|
||||
h, m, rest = timestamp.split(":")
|
||||
s, ms = rest.split(",")
|
||||
return int(h) * 3600 + int(m) * 60 + int(s) + int(ms) / 1000
|
||||
|
||||
|
||||
def read_srt(path: Path) -> list[tuple[float, float, str]]:
|
||||
text = path.read_text(encoding="utf-8-sig")
|
||||
captions = []
|
||||
for _, start, end, body in SRT_BLOCK_RE.findall(text):
|
||||
clean_text = " ".join(line.strip() for line in body.strip().splitlines())
|
||||
captions.append((to_seconds(start), to_seconds(end), clean_text))
|
||||
if not captions:
|
||||
raise ValueError(f"No captions found in {path}")
|
||||
return captions
|
||||
|
||||
|
||||
def resolve_path(value: str) -> Path:
|
||||
return Path(value).expanduser().resolve()
|
||||
|
||||
|
||||
def ps_quote(value: str) -> str:
|
||||
return "'" + value.replace("'", "''") + "'"
|
||||
|
||||
|
||||
def media_duration(path: Path) -> float:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"ffprobe",
|
||||
"-v", "error",
|
||||
"-show_entries", "format=duration",
|
||||
"-of", "default=noprint_wrappers=1:nokey=1",
|
||||
str(path),
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return float(result.stdout.strip())
|
||||
|
||||
|
||||
def run(command: list[str]) -> None:
|
||||
print("Running:", " ".join(command))
|
||||
subprocess.run(command, check=True)
|
||||
|
||||
|
||||
def speak_to_wav(text: str, voice: str, rate: int, output: Path) -> None:
|
||||
ps_script = f"""
|
||||
Add-Type -AssemblyName System.Speech
|
||||
$synth = New-Object System.Speech.Synthesis.SpeechSynthesizer
|
||||
$synth.SelectVoice({ps_quote(voice)})
|
||||
$synth.Rate = {rate}
|
||||
$synth.SetOutputToWaveFile({ps_quote(str(output))})
|
||||
$synth.Speak({ps_quote(text)})
|
||||
$synth.Dispose()
|
||||
"""
|
||||
run(["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", ps_script])
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Create a timed voiceover WAV/MP3 from an SRT file on Windows.")
|
||||
parser.add_argument("--srt", required=True, help="Input SRT file")
|
||||
parser.add_argument("--voice", default="Microsoft David Desktop", help="Windows voice name")
|
||||
parser.add_argument("--rate", type=int, default=0, help="Windows speech rate from -10 to 10. Higher is faster.")
|
||||
parser.add_argument("--wav", required=True, help="Output WAV file")
|
||||
parser.add_argument("--mp3", default=None, help="Optional output MP3 file")
|
||||
args = parser.parse_args()
|
||||
|
||||
srt_path = resolve_path(args.srt)
|
||||
wav_output = resolve_path(args.wav)
|
||||
mp3_output = resolve_path(args.mp3) if args.mp3 else None
|
||||
work_dir = Path(__file__).resolve().parent / "voice_parts"
|
||||
|
||||
if not srt_path.exists():
|
||||
raise FileNotFoundError(f"SRT not found: {srt_path}")
|
||||
|
||||
work_dir.mkdir(parents=True, exist_ok=True)
|
||||
wav_output.parent.mkdir(parents=True, exist_ok=True)
|
||||
if mp3_output:
|
||||
mp3_output.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
captions = read_srt(srt_path)
|
||||
segment_files = []
|
||||
|
||||
for i, (start, end, text) in enumerate(captions, 1):
|
||||
duration = end - start
|
||||
raw_wav = work_dir / f"raw_{i:03d}.wav"
|
||||
segment_wav = work_dir / f"seg_{i:03d}.wav"
|
||||
|
||||
speak_to_wav(text, args.voice, args.rate, raw_wav)
|
||||
|
||||
spoken_duration = media_duration(raw_wav)
|
||||
if spoken_duration > duration:
|
||||
print(f"Warning: caption {i} speech is {spoken_duration:.2f}s but slot is {duration:.2f}s; it will be trimmed.")
|
||||
|
||||
run([
|
||||
"ffmpeg", "-y",
|
||||
"-i", str(raw_wav),
|
||||
"-af", f"apad,atrim=0:{duration}",
|
||||
"-ar", "44100",
|
||||
"-ac", "2",
|
||||
str(segment_wav),
|
||||
])
|
||||
segment_files.append(segment_wav)
|
||||
|
||||
concat_list = work_dir / "list.txt"
|
||||
concat_list.write_text("".join(f"file '{os.path.abspath(file)}'\n" for file in segment_files), encoding="utf-8")
|
||||
|
||||
run(["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(concat_list), "-c:a", "pcm_s16le", str(wav_output)])
|
||||
print(f"Done: {wav_output}")
|
||||
|
||||
if mp3_output:
|
||||
run(["ffmpeg", "-y", "-i", str(wav_output), "-codec:a", "libmp3lame", "-b:a", "192k", str(mp3_output)])
|
||||
print(f"Done: {mp3_output}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user