129 lines
4.5 KiB
Python
Executable File
129 lines
4.5 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Trim silence and normalize generated audio candidates with ffmpeg.
|
|
|
|
Raw generations stay untouched under `raw/`; processed files land beside them.
|
|
Deterministic, no network, no key access.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
TARGET_PEAK_DB = -1.5
|
|
|
|
|
|
def probe_duration(path: Path) -> float:
|
|
out = subprocess.run(
|
|
["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "json", str(path)],
|
|
capture_output=True, text=True, check=True,
|
|
)
|
|
return float(json.loads(out.stdout)["format"]["duration"])
|
|
|
|
|
|
def measure_peak(path: Path) -> float:
|
|
out = subprocess.run(
|
|
["ffmpeg", "-hide_banner", "-i", str(path), "-af", "volumedetect", "-f", "null", "-"],
|
|
capture_output=True, text=True, check=False,
|
|
)
|
|
for line in out.stderr.splitlines():
|
|
if "max_volume:" in line:
|
|
return float(line.split("max_volume:")[1].strip().split(" ")[0])
|
|
return 0.0
|
|
|
|
|
|
def process(source: Path, target: Path) -> dict:
|
|
peak = measure_peak(source)
|
|
gain = TARGET_PEAK_DB - peak if peak < 0 else 0.0
|
|
filters = (
|
|
"silenceremove=start_periods=1:start_duration=0.002:start_threshold=-50dB,"
|
|
"areverse,"
|
|
"silenceremove=start_periods=1:start_duration=0.002:start_threshold=-50dB,"
|
|
"areverse,"
|
|
"apad=pad_dur=0.08,"
|
|
f"volume={gain:.2f}dB"
|
|
)
|
|
subprocess.run(
|
|
["ffmpeg", "-y", "-v", "error", "-i", str(source), "-af", filters,
|
|
"-c:a", "libmp3lame", "-q:a", "2", str(target)],
|
|
check=True,
|
|
)
|
|
# mp3 encoding can overshoot the requested gain; pull it back once if needed.
|
|
rendered_peak = measure_peak(target)
|
|
if rendered_peak > -1.0:
|
|
correction = -1.5 - rendered_peak
|
|
corrected = target.with_name(target.stem + ".corrected.mp3")
|
|
subprocess.run(
|
|
["ffmpeg", "-y", "-v", "error", "-i", str(target), "-af", f"volume={correction:.2f}dB",
|
|
"-c:a", "libmp3lame", "-q:a", "2", str(corrected)],
|
|
check=True,
|
|
)
|
|
corrected.replace(target)
|
|
gain += correction
|
|
return {
|
|
"source": str(source),
|
|
"output": str(target),
|
|
"source_peak_dbfs": round(peak, 2),
|
|
"applied_gain_db": round(gain, 2),
|
|
"duration_seconds": round(probe_duration(target), 3),
|
|
}
|
|
|
|
|
|
def process_music(source: Path, target: Path) -> dict:
|
|
"""Loudness-normalize a music bed without touching its rests or loop length."""
|
|
subprocess.run(
|
|
["ffmpeg", "-y", "-v", "error", "-i", str(source),
|
|
"-af", "loudnorm=I=-18:TP=-1.5:LRA=11",
|
|
"-c:a", "libmp3lame", "-q:a", "2", str(target)],
|
|
check=True,
|
|
)
|
|
return {
|
|
"source": str(source),
|
|
"output": str(target),
|
|
"mode": "music_loudnorm",
|
|
"duration_seconds": round(probe_duration(target), 3),
|
|
}
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("paths", nargs="+")
|
|
parser.add_argument("--music", action="store_true", help="loudness-normalize without trimming silence")
|
|
parser.add_argument("--report", default=None)
|
|
args = parser.parse_args()
|
|
if args.music:
|
|
raw_dir = Path(args.paths[0]).parent / "raw"
|
|
raw_dir.mkdir(parents=True, exist_ok=True)
|
|
records = []
|
|
for raw in args.paths:
|
|
source = Path(raw)
|
|
archived = raw_dir / source.name
|
|
if not archived.exists():
|
|
archived.write_bytes(source.read_bytes())
|
|
record = process_music(archived, source)
|
|
records.append(record)
|
|
print(f"OK {source.name} dur {record['duration_seconds']}")
|
|
if args.report:
|
|
Path(args.report).write_text(json.dumps({"items": records}, ensure_ascii=False, indent=1))
|
|
return 0
|
|
raw_dir = Path(args.paths[0]).parent / "raw"
|
|
raw_dir.mkdir(parents=True, exist_ok=True)
|
|
records = []
|
|
for raw in args.paths:
|
|
source = Path(raw)
|
|
archived = raw_dir / source.name
|
|
if not archived.exists():
|
|
archived.write_bytes(source.read_bytes())
|
|
record = process(archived, source)
|
|
records.append(record)
|
|
print(f"OK {source.name} peak {record['source_peak_dbfs']} gain {record['applied_gain_db']} dur {record['duration_seconds']}")
|
|
if args.report:
|
|
Path(args.report).write_text(json.dumps({"items": records}, ensure_ascii=False, indent=1))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|