feat: more optional options
This commit is contained in:
@@ -157,8 +157,32 @@ python video-generator/scripts/combine_video_audio.py \
|
|||||||
--rate 190
|
--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
|
## 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 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.
|
|
||||||
@@ -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])
|
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"""
|
ps_script = f"""
|
||||||
Add-Type -AssemblyName System.Speech
|
Add-Type -AssemblyName System.Speech
|
||||||
$synth = New-Object System.Speech.Synthesis.SpeechSynthesizer
|
$synth = New-Object System.Speech.Synthesis.SpeechSynthesizer
|
||||||
$synth.SelectVoice({ps_quote(voice)})
|
$synth.SelectVoice({ps_quote(voice)})
|
||||||
$synth.Rate = {rate}
|
$synth.Rate = {rate}
|
||||||
|
$synth.Volume = {volume}
|
||||||
$synth.SetOutputToWaveFile({ps_quote(str(output))})
|
$synth.SetOutputToWaveFile({ps_quote(str(output))})
|
||||||
$synth.Speak({ps_quote(text)})
|
$synth.Speak({ps_quote(text)})
|
||||||
$synth.Dispose()
|
$synth.Dispose()
|
||||||
@@ -101,18 +102,29 @@ def default_rate() -> int:
|
|||||||
raise RuntimeError("One-step SRT voiceover generation is supported only on macOS and Windows.")
|
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()
|
system = platform.system()
|
||||||
if system == "Darwin":
|
if system == "Darwin":
|
||||||
speak_macos(text, voice, rate, output)
|
speak_macos(text, voice, rate, output)
|
||||||
return
|
return
|
||||||
if system == "Windows":
|
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
|
return
|
||||||
raise RuntimeError("One-step SRT voiceover generation is supported only on macOS and Windows.")
|
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)
|
captions = read_srt(srt_path)
|
||||||
segment_files = []
|
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"
|
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"
|
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)
|
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:
|
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.")
|
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([
|
run([
|
||||||
"ffmpeg", "-y",
|
"ffmpeg", "-y",
|
||||||
"-i", str(raw_audio),
|
"-i", str(raw_audio),
|
||||||
"-af", f"apad,atrim=0:{duration}",
|
"-af", audio_filter,
|
||||||
"-ar", "44100",
|
"-ar", str(sample_rate),
|
||||||
"-ac", "2",
|
"-ac", str(channels),
|
||||||
str(segment_wav),
|
str(segment_wav),
|
||||||
])
|
])
|
||||||
segment_files.append(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)])
|
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"]
|
video_codec_args = ["-c:v", "libx264", "-preset", "medium", "-crf", "18"] if reencode_video else ["-c:v", "copy"]
|
||||||
|
|
||||||
command = [
|
command = [
|
||||||
@@ -152,6 +169,7 @@ def combine_video_audio(video_file: Path, audio_file: Path, output_file: Path, r
|
|||||||
"-map", "0:v:0",
|
"-map", "0:v:0",
|
||||||
"-map", "1:a:0",
|
"-map", "1:a:0",
|
||||||
*video_codec_args,
|
*video_codec_args,
|
||||||
|
"-af", f"volume={volume}",
|
||||||
"-c:a", "aac",
|
"-c:a", "aac",
|
||||||
"-b:a", "192k",
|
"-b:a", "192k",
|
||||||
"-movflags", "+faststart",
|
"-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("--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("--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("--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(
|
parser.add_argument(
|
||||||
"--reencode-video",
|
"--reencode-video",
|
||||||
action="store_true",
|
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.")
|
parser.error("Provide either --audio for the current two-step flow or --srt for one-step generation.")
|
||||||
if args.audio and args.srt:
|
if args.audio and args.srt:
|
||||||
parser.error("Use either --audio or --srt, not both.")
|
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)
|
video_file = resolve_path(args.video)
|
||||||
output_file = resolve_path(args.output)
|
output_file = resolve_path(args.output)
|
||||||
@@ -194,7 +222,7 @@ def main() -> None:
|
|||||||
audio_file = resolve_path(args.audio)
|
audio_file = resolve_path(args.audio)
|
||||||
if not audio_file.exists():
|
if not audio_file.exists():
|
||||||
raise FileNotFoundError(f"Audio not found: {audio_file}")
|
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:
|
else:
|
||||||
srt_path = resolve_path(args.srt)
|
srt_path = resolve_path(args.srt)
|
||||||
if not srt_path.exists():
|
if not srt_path.exists():
|
||||||
@@ -202,14 +230,33 @@ def main() -> None:
|
|||||||
|
|
||||||
voice = args.voice or default_voice()
|
voice = args.voice or default_voice()
|
||||||
rate = args.rate if args.rate is not None else default_rate()
|
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_"))
|
temp_dir = Path(tempfile.mkdtemp(prefix="video_generator_"))
|
||||||
try:
|
try:
|
||||||
temp_wav = temp_dir / "voiceover.wav"
|
temp_wav = temp_dir / "voiceover.wav"
|
||||||
create_voiceover_from_srt(srt_path, temp_wav, voice, rate, temp_dir)
|
create_voiceover_from_srt(
|
||||||
combine_video_audio(video_file, temp_wav, output_file, args.reencode_video)
|
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:
|
finally:
|
||||||
|
if not args.keep_temp:
|
||||||
remove_temp_dir(temp_dir)
|
remove_temp_dir(temp_dir)
|
||||||
|
else:
|
||||||
|
print(f"Kept temporary voice parts: {temp_dir}")
|
||||||
|
|
||||||
print(f"Done: {output_file}")
|
print(f"Done: {output_file}")
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import argparse
|
import argparse
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -33,7 +34,13 @@ def resolve_path(value: str) -> Path:
|
|||||||
|
|
||||||
def media_duration(path: Path) -> float:
|
def media_duration(path: Path) -> float:
|
||||||
result = subprocess.run(
|
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,
|
check=True,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
@@ -46,6 +53,15 @@ def run(command: list[str]) -> None:
|
|||||||
subprocess.run(command, check=True)
|
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:
|
def main() -> None:
|
||||||
parser = argparse.ArgumentParser(description="Create a timed voiceover WAV/MP3 from an SRT file on macOS.")
|
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("--srt", required=True, help="Input SRT file")
|
||||||
@@ -53,24 +69,38 @@ def main() -> None:
|
|||||||
parser.add_argument("--rate", type=int, default=185, help="Speech rate. Higher is faster. Example: 180-200")
|
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("--wav", required=True, help="Output WAV file")
|
||||||
parser.add_argument("--mp3", default=None, help="Optional output MP3 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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
srt_path = resolve_path(args.srt)
|
srt_path = resolve_path(args.srt)
|
||||||
wav_output = resolve_path(args.wav)
|
wav_output = resolve_path(args.wav)
|
||||||
mp3_output = resolve_path(args.mp3) if args.mp3 else None
|
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"
|
work_dir = Path(__file__).resolve().parent / "voice_parts"
|
||||||
|
|
||||||
if not srt_path.exists():
|
if not srt_path.exists():
|
||||||
raise FileNotFoundError(f"SRT not found: {srt_path}")
|
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)
|
work_dir.mkdir(parents=True, exist_ok=True)
|
||||||
wav_output.parent.mkdir(parents=True, exist_ok=True)
|
wav_output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
if mp3_output:
|
if mp3_output:
|
||||||
mp3_output.parent.mkdir(parents=True, exist_ok=True)
|
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)
|
captions = read_srt(srt_path)
|
||||||
segment_files = []
|
segment_files = []
|
||||||
|
|
||||||
|
try:
|
||||||
for i, (start, end, text) in enumerate(captions, 1):
|
for i, (start, end, text) in enumerate(captions, 1):
|
||||||
duration = end - start
|
duration = end - start
|
||||||
raw_aiff = work_dir / f"raw_{i:03d}.aiff"
|
raw_aiff = work_dir / f"raw_{i:03d}.aiff"
|
||||||
@@ -79,15 +109,20 @@ def main() -> None:
|
|||||||
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)
|
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)
|
||||||
|
|
||||||
if spoken_duration > duration:
|
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.")
|
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={args.volume},apad,atrim=0:{effective_duration}"
|
||||||
|
|
||||||
run([
|
run([
|
||||||
"ffmpeg", "-y",
|
"ffmpeg", "-y",
|
||||||
"-i", str(raw_aiff),
|
"-i", str(raw_aiff),
|
||||||
"-af", f"apad,atrim=0:{duration}",
|
"-af", audio_filter,
|
||||||
"-ar", "44100",
|
"-ar", str(args.sample_rate),
|
||||||
"-ac", "2",
|
"-ac", str(args.channels),
|
||||||
str(segment_wav),
|
str(segment_wav),
|
||||||
])
|
])
|
||||||
segment_files.append(segment_wav)
|
segment_files.append(segment_wav)
|
||||||
@@ -98,9 +133,16 @@ def main() -> None:
|
|||||||
run(["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(concat_list), "-c:a", "pcm_s16le", str(wav_output)])
|
run(["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(concat_list), "-c:a", "pcm_s16le", str(wav_output)])
|
||||||
print(f"Done: {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:
|
if mp3_output:
|
||||||
run(["ffmpeg", "-y", "-i", str(wav_output), "-codec:a", "libmp3lame", "-b:a", "192k", str(mp3_output)])
|
run(["ffmpeg", "-y", "-i", str(wav_output), "-codec:a", "libmp3lame", "-b:a", "192k", str(mp3_output)])
|
||||||
print(f"Done: {mp3_output}")
|
print(f"Done: {mp3_output}")
|
||||||
|
finally:
|
||||||
|
if not args.keep_temp:
|
||||||
|
remove_temp_dir(work_dir)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import argparse
|
import argparse
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -56,12 +57,22 @@ def run(command: list[str]) -> None:
|
|||||||
subprocess.run(command, check=True)
|
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"""
|
ps_script = f"""
|
||||||
Add-Type -AssemblyName System.Speech
|
Add-Type -AssemblyName System.Speech
|
||||||
$synth = New-Object System.Speech.Synthesis.SpeechSynthesizer
|
$synth = New-Object System.Speech.Synthesis.SpeechSynthesizer
|
||||||
$synth.SelectVoice({ps_quote(voice)})
|
$synth.SelectVoice({ps_quote(voice)})
|
||||||
$synth.Rate = {rate}
|
$synth.Rate = {rate}
|
||||||
|
$synth.Volume = {volume}
|
||||||
$synth.SetOutputToWaveFile({ps_quote(str(output))})
|
$synth.SetOutputToWaveFile({ps_quote(str(output))})
|
||||||
$synth.Speak({ps_quote(text)})
|
$synth.Speak({ps_quote(text)})
|
||||||
$synth.Dispose()
|
$synth.Dispose()
|
||||||
@@ -76,41 +87,64 @@ 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("--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("--wav", required=True, help="Output WAV file")
|
||||||
parser.add_argument("--mp3", default=None, help="Optional output MP3 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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
srt_path = resolve_path(args.srt)
|
srt_path = resolve_path(args.srt)
|
||||||
wav_output = resolve_path(args.wav)
|
wav_output = resolve_path(args.wav)
|
||||||
mp3_output = resolve_path(args.mp3) if args.mp3 else None
|
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"
|
work_dir = Path(__file__).resolve().parent / "voice_parts"
|
||||||
|
|
||||||
if not srt_path.exists():
|
if not srt_path.exists():
|
||||||
raise FileNotFoundError(f"SRT not found: {srt_path}")
|
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)
|
work_dir.mkdir(parents=True, exist_ok=True)
|
||||||
wav_output.parent.mkdir(parents=True, exist_ok=True)
|
wav_output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
if mp3_output:
|
if mp3_output:
|
||||||
mp3_output.parent.mkdir(parents=True, exist_ok=True)
|
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)
|
captions = read_srt(srt_path)
|
||||||
segment_files = []
|
segment_files = []
|
||||||
|
|
||||||
|
try:
|
||||||
for i, (start, end, text) in enumerate(captions, 1):
|
for i, (start, end, text) in enumerate(captions, 1):
|
||||||
duration = end - start
|
duration = end - start
|
||||||
raw_wav = work_dir / f"raw_{i:03d}.wav"
|
raw_wav = work_dir / f"raw_{i:03d}.wav"
|
||||||
segment_wav = work_dir / f"seg_{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)
|
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)
|
||||||
|
|
||||||
if spoken_duration > duration:
|
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.")
|
print(f"Warning: caption {i} speech is {spoken_duration:.2f}s but slot is {duration:.2f}s; it will be trimmed.")
|
||||||
|
|
||||||
|
# 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([
|
run([
|
||||||
"ffmpeg", "-y",
|
"ffmpeg", "-y",
|
||||||
"-i", str(raw_wav),
|
"-i", str(raw_wav),
|
||||||
"-af", f"apad,atrim=0:{duration}",
|
"-af", audio_filter,
|
||||||
"-ar", "44100",
|
"-ar", str(args.sample_rate),
|
||||||
"-ac", "2",
|
"-ac", str(args.channels),
|
||||||
str(segment_wav),
|
str(segment_wav),
|
||||||
])
|
])
|
||||||
segment_files.append(segment_wav)
|
segment_files.append(segment_wav)
|
||||||
@@ -121,9 +155,16 @@ def main() -> None:
|
|||||||
run(["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(concat_list), "-c:a", "pcm_s16le", str(wav_output)])
|
run(["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(concat_list), "-c:a", "pcm_s16le", str(wav_output)])
|
||||||
print(f"Done: {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:
|
if mp3_output:
|
||||||
run(["ffmpeg", "-y", "-i", str(wav_output), "-codec:a", "libmp3lame", "-b:a", "192k", str(mp3_output)])
|
run(["ffmpeg", "-y", "-i", str(wav_output), "-codec:a", "libmp3lame", "-b:a", "192k", str(mp3_output)])
|
||||||
print(f"Done: {mp3_output}")
|
print(f"Done: {mp3_output}")
|
||||||
|
finally:
|
||||||
|
if not args.keep_temp:
|
||||||
|
remove_temp_dir(work_dir)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in New Issue
Block a user