feat: more optional options

This commit is contained in:
Tutorial Builder
2026-06-16 13:26:47 +03:30
parent cad4e681c3
commit 302aa2e778
4 changed files with 220 additions and 66 deletions
+27 -3
View File
@@ -157,8 +157,32 @@ python video-generator/scripts/combine_video_audio.py \
--rate 190
```
## Optional advanced options
These options are not needed for normal usage.
- `--audio-output "output/voiceover.wav"` — saves a copy of the generated voiceover WAV in one-step mode.
- `--keep-temp` — keeps temporary voice parts for debugging. By default, temporary files are deleted.
- `--volume 1.2` — changes voiceover volume. Default is `1.0`.
- `--silence-padding 0.15` — adds a small pause after each caption if the SRT time slot has enough space. Default is `0.0`.
- `--sample-rate 48000` — changes generated audio sample rate. Default is `44100`.
- `--channels 1` — changes generated audio channels. Use `1` for mono or `2` for stereo. Default is `2`.
- `--reencode-video` — re-encodes video with H.264 if the copied video output has compatibility issues.
Example:
```bash
python video-generator/scripts/combine_video_audio.py \
--video "video-generator/input/your_video.mp4" \
--srt "video-generator/subtitles/your_subtitles.srt" \
--output "video-generator/output/final_video.mp4" \
--audio-output "video-generator/output/voiceover.wav" \
--volume 1.2 \
--silence-padding 0.15 \
--sample-rate 48000 \
--channels 2
```
## 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.
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.
+60 -13
View File
@@ -72,12 +72,13 @@ def speak_macos(text: str, voice: str, rate: int, output: Path) -> None:
run(["say", "-v", voice, "-r", str(rate), "-o", str(output), text])
def speak_windows(text: str, voice: str, rate: int, output: Path) -> None:
def speak_windows(text: str, voice: str, rate: int, volume: 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.Volume = {volume}
$synth.SetOutputToWaveFile({ps_quote(str(output))})
$synth.Speak({ps_quote(text)})
$synth.Dispose()
@@ -101,18 +102,29 @@ def default_rate() -> int:
raise RuntimeError("One-step SRT voiceover generation is supported only on macOS and Windows.")
def speak(text: str, voice: str, rate: int, output: Path) -> None:
def speak(text: str, voice: str, rate: int, volume: float, output: Path) -> None:
system = platform.system()
if system == "Darwin":
speak_macos(text, voice, rate, output)
return
if system == "Windows":
speak_windows(text, voice, rate, output)
windows_volume = max(0, min(100, round(volume * 100)))
speak_windows(text, voice, rate, windows_volume, output)
return
raise RuntimeError("One-step SRT voiceover generation is supported only on macOS and Windows.")
def create_voiceover_from_srt(srt_path: Path, wav_output: Path, voice: str, rate: int, work_dir: Path) -> None:
def create_voiceover_from_srt(
srt_path: Path,
wav_output: Path,
voice: str,
rate: int,
work_dir: Path,
volume: float,
silence_padding: float,
sample_rate: int,
channels: int,
) -> None:
captions = read_srt(srt_path)
segment_files = []
@@ -121,18 +133,23 @@ def create_voiceover_from_srt(srt_path: Path, wav_output: Path, voice: str, rate
raw_audio = work_dir / f"raw_{i:03d}.aiff" if platform.system() == "Darwin" else work_dir / f"raw_{i:03d}.wav"
segment_wav = work_dir / f"seg_{i:03d}.wav"
speak(text, voice, rate, raw_audio)
speak(text, voice, rate, volume, raw_audio)
spoken_duration = media_duration(raw_audio)
padding = min(silence_padding, max(duration - spoken_duration, 0))
effective_duration = duration if silence_padding == 0 else min(duration, spoken_duration + padding)
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.")
audio_filter = f"volume={volume},apad,atrim=0:{effective_duration}"
run([
"ffmpeg", "-y",
"-i", str(raw_audio),
"-af", f"apad,atrim=0:{duration}",
"-ar", "44100",
"-ac", "2",
"-af", audio_filter,
"-ar", str(sample_rate),
"-ac", str(channels),
str(segment_wav),
])
segment_files.append(segment_wav)
@@ -142,7 +159,7 @@ def create_voiceover_from_srt(srt_path: Path, wav_output: Path, voice: str, rate
run(["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(concat_list), "-c:a", "pcm_s16le", str(wav_output)])
def combine_video_audio(video_file: Path, audio_file: Path, output_file: Path, reencode_video: bool) -> None:
def combine_video_audio(video_file: Path, audio_file: Path, output_file: Path, reencode_video: bool, volume: float) -> None:
video_codec_args = ["-c:v", "libx264", "-preset", "medium", "-crf", "18"] if reencode_video else ["-c:v", "copy"]
command = [
@@ -152,6 +169,7 @@ def combine_video_audio(video_file: Path, audio_file: Path, output_file: Path, r
"-map", "0:v:0",
"-map", "1:a:0",
*video_codec_args,
"-af", f"volume={volume}",
"-c:a", "aac",
"-b:a", "192k",
"-movflags", "+faststart",
@@ -170,6 +188,12 @@ def main() -> None:
parser.add_argument("--output", required=True, help="Output MP4 file, for example output/final_video.mp4")
parser.add_argument("--voice", default=None, help="Voice name for one-step SRT mode")
parser.add_argument("--rate", type=int, default=None, help="Speech rate for one-step SRT mode")
parser.add_argument("--audio-output", default=None, help="Optional copy of the generated voiceover WAV in one-step SRT mode")
parser.add_argument("--keep-temp", action="store_true", help="Keep temporary voice parts for debugging in one-step SRT mode")
parser.add_argument("--volume", type=float, default=1.0, help="Voiceover volume multiplier. Default: 1.0")
parser.add_argument("--silence-padding", type=float, default=0.0, help="Seconds of silence after each caption in one-step SRT mode, if the SRT slot allows it. Default: 0.0")
parser.add_argument("--sample-rate", type=int, default=44100, help="Generated voiceover sample rate in one-step SRT mode. Default: 44100")
parser.add_argument("--channels", type=int, default=2, choices=[1, 2], help="Generated voiceover channels in one-step SRT mode. Default: 2")
parser.add_argument(
"--reencode-video",
action="store_true",
@@ -181,6 +205,10 @@ def main() -> None:
parser.error("Provide either --audio for the current two-step flow or --srt for one-step generation.")
if args.audio and args.srt:
parser.error("Use either --audio or --srt, not both.")
if args.volume <= 0:
parser.error("--volume must be greater than 0.")
if args.silence_padding < 0:
parser.error("--silence-padding must be 0 or greater.")
video_file = resolve_path(args.video)
output_file = resolve_path(args.output)
@@ -194,7 +222,7 @@ def main() -> None:
audio_file = resolve_path(args.audio)
if not audio_file.exists():
raise FileNotFoundError(f"Audio not found: {audio_file}")
combine_video_audio(video_file, audio_file, output_file, args.reencode_video)
combine_video_audio(video_file, audio_file, output_file, args.reencode_video, args.volume)
else:
srt_path = resolve_path(args.srt)
if not srt_path.exists():
@@ -202,14 +230,33 @@ def main() -> None:
voice = args.voice or default_voice()
rate = args.rate if args.rate is not None else default_rate()
audio_output = resolve_path(args.audio_output) if args.audio_output else None
if audio_output:
audio_output.parent.mkdir(parents=True, exist_ok=True)
temp_dir = Path(tempfile.mkdtemp(prefix="video_generator_"))
try:
temp_wav = temp_dir / "voiceover.wav"
create_voiceover_from_srt(srt_path, temp_wav, voice, rate, temp_dir)
combine_video_audio(video_file, temp_wav, output_file, args.reencode_video)
create_voiceover_from_srt(
srt_path,
temp_wav,
voice,
rate,
temp_dir,
args.volume,
args.silence_padding,
args.sample_rate,
args.channels,
)
if audio_output:
run(["ffmpeg", "-y", "-i", str(temp_wav), "-c:a", "pcm_s16le", str(audio_output)])
print(f"Done: {audio_output}")
combine_video_audio(video_file, temp_wav, output_file, args.reencode_video, 1.0)
finally:
remove_temp_dir(temp_dir)
if not args.keep_temp:
remove_temp_dir(temp_dir)
else:
print(f"Kept temporary voice parts: {temp_dir}")
print(f"Done: {output_file}")
+67 -25
View File
@@ -1,6 +1,7 @@
import argparse
import os
import re
import shutil
import subprocess
from pathlib import Path
@@ -33,7 +34,13 @@ def resolve_path(value: str) -> Path:
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)],
[
"ffprobe",
"-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
str(path),
],
check=True,
capture_output=True,
text=True,
@@ -46,6 +53,15 @@ def run(command: list[str]) -> None:
subprocess.run(command, check=True)
def remove_temp_dir(path: Path) -> None:
try:
shutil.rmtree(path, ignore_errors=True)
except PermissionError:
pass
except OSError:
pass
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")
@@ -53,54 +69,80 @@ def main() -> None:
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")
parser.add_argument("--audio-output", default=None, help="Optional extra copy of the generated WAV file")
parser.add_argument("--keep-temp", action="store_true", help="Keep temporary voice parts for debugging")
parser.add_argument("--volume", type=float, default=1.0, help="Voiceover volume multiplier. Default: 1.0")
parser.add_argument("--silence-padding", type=float, default=0.0, help="Seconds of silence after each caption, if the SRT slot allows it. Default: 0.0")
parser.add_argument("--sample-rate", type=int, default=44100, help="Output sample rate. Default: 44100")
parser.add_argument("--channels", type=int, default=2, choices=[1, 2], help="Output audio channels. Default: 2")
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
audio_output = resolve_path(args.audio_output) if args.audio_output else None
work_dir = Path(__file__).resolve().parent / "voice_parts"
if not srt_path.exists():
raise FileNotFoundError(f"SRT not found: {srt_path}")
if args.volume <= 0:
raise ValueError("--volume must be greater than 0")
if args.silence_padding < 0:
raise ValueError("--silence-padding must be 0 or greater")
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)
if audio_output:
audio_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"
try:
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])
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.")
spoken_duration = media_duration(raw_aiff)
padding = min(args.silence_padding, max(duration - spoken_duration, 0))
effective_duration = duration if args.silence_padding == 0 else min(duration, spoken_duration + padding)
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)
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.")
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")
audio_filter = f"volume={args.volume},apad,atrim=0:{effective_duration}"
run(["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(concat_list), "-c:a", "pcm_s16le", str(wav_output)])
print(f"Done: {wav_output}")
run([
"ffmpeg", "-y",
"-i", str(raw_aiff),
"-af", audio_filter,
"-ar", str(args.sample_rate),
"-ac", str(args.channels),
str(segment_wav),
])
segment_files.append(segment_wav)
if mp3_output:
run(["ffmpeg", "-y", "-i", str(wav_output), "-codec:a", "libmp3lame", "-b:a", "192k", str(mp3_output)])
print(f"Done: {mp3_output}")
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 audio_output:
run(["ffmpeg", "-y", "-i", str(wav_output), "-c:a", "pcm_s16le", str(audio_output)])
print(f"Done: {audio_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}")
finally:
if not args.keep_temp:
remove_temp_dir(work_dir)
if __name__ == "__main__":
@@ -1,6 +1,7 @@
import argparse
import os
import re
import shutil
import subprocess
from pathlib import Path
@@ -56,12 +57,22 @@ def run(command: list[str]) -> None:
subprocess.run(command, check=True)
def speak_to_wav(text: str, voice: str, rate: int, output: Path) -> None:
def remove_temp_dir(path: Path) -> None:
try:
shutil.rmtree(path, ignore_errors=True)
except PermissionError:
pass
except OSError:
pass
def speak_to_wav(text: str, voice: str, rate: int, volume: 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.Volume = {volume}
$synth.SetOutputToWaveFile({ps_quote(str(output))})
$synth.Speak({ps_quote(text)})
$synth.Dispose()
@@ -76,54 +87,84 @@ def main() -> None:
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")
parser.add_argument("--audio-output", default=None, help="Optional extra copy of the generated WAV file")
parser.add_argument("--keep-temp", action="store_true", help="Keep temporary voice parts for debugging")
parser.add_argument("--volume", type=float, default=1.0, help="Voiceover volume multiplier. Default: 1.0")
parser.add_argument("--silence-padding", type=float, default=0.0, help="Seconds of silence after each caption, if the SRT slot allows it. Default: 0.0")
parser.add_argument("--sample-rate", type=int, default=44100, help="Output sample rate. Default: 44100")
parser.add_argument("--channels", type=int, default=2, choices=[1, 2], help="Output audio channels. Default: 2")
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
audio_output = resolve_path(args.audio_output) if args.audio_output else None
work_dir = Path(__file__).resolve().parent / "voice_parts"
if not srt_path.exists():
raise FileNotFoundError(f"SRT not found: {srt_path}")
if args.volume <= 0:
raise ValueError("--volume must be greater than 0")
if args.silence_padding < 0:
raise ValueError("--silence-padding must be 0 or greater")
windows_volume = max(0, min(100, round(args.volume * 100)))
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)
if audio_output:
audio_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"
try:
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)
speak_to_wav(text, args.voice, args.rate, windows_volume, 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.")
spoken_duration = media_duration(raw_wav)
padding = min(args.silence_padding, max(duration - spoken_duration, 0))
effective_duration = duration if args.silence_padding == 0 else min(duration, spoken_duration + padding)
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)
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.")
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")
# Windows System.Speech volume is limited to 0-100. Use ffmpeg volume only for multipliers above 1.0.
ffmpeg_volume = max(args.volume, 1.0)
audio_filter = f"volume={ffmpeg_volume},apad,atrim=0:{effective_duration}"
run(["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(concat_list), "-c:a", "pcm_s16le", str(wav_output)])
print(f"Done: {wav_output}")
run([
"ffmpeg", "-y",
"-i", str(raw_wav),
"-af", audio_filter,
"-ar", str(args.sample_rate),
"-ac", str(args.channels),
str(segment_wav),
])
segment_files.append(segment_wav)
if mp3_output:
run(["ffmpeg", "-y", "-i", str(wav_output), "-codec:a", "libmp3lame", "-b:a", "192k", str(mp3_output)])
print(f"Done: {mp3_output}")
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 audio_output:
run(["ffmpeg", "-y", "-i", str(wav_output), "-c:a", "pcm_s16le", str(audio_output)])
print(f"Done: {audio_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}")
finally:
if not args.keep_temp:
remove_temp_dir(work_dir)
if __name__ == "__main__":