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
@@ -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__":