diff --git a/Makefile b/Makefile index c800524..a2cc20d 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,7 @@ else PIP_INSTALL = $(PIP) install endif -.PHONY: default install install-cpu install-rocm install-cuda extract ast sync verify clean help +.PHONY: default install install-cpu install-rocm install-cuda extract extract-loud ast sync verify clean help default: ast @@ -25,6 +25,7 @@ help: @echo " make install-rocm # force ROCm torch (RX 7900 XTX gfx1100)" @echo " make install-cuda # force CUDA torch" @echo " make extract # run extract.py (uses config.toml if present)" + @echo " make extract-loud # extract with loud-only gating (keep explosions/transients)" @echo " make ast # run sync_audio_test.py (audio_sync_test, PyQtGraph GUI)" @echo " make sync LAG=0.042 # run apply_sync.py to mux synced output (LAG in seconds)" @echo " make verify LAG=0.042 # verify sync: video + dual waveform player" @@ -82,6 +83,9 @@ install-cuda: extract: @if [ -x $(PY) ]; then $(PY) extract.py; else python3 extract.py; fi +extract-loud: + @if [ -x $(PY) ]; then $(PY) extract.py loud; else python3 extract.py loud; fi + sync: @if [ -x $(PY) ]; then PYTHONPATH=. $(PY) apply_sync.py --config $(CONFIG) --lag $(LAG); else PYTHONPATH=. python3 apply_sync.py --config $(CONFIG) --lag $(LAG); fi diff --git a/extract.py b/extract.py index 5f6a105..223f17a 100644 --- a/extract.py +++ b/extract.py @@ -5,6 +5,10 @@ Extracts the first 1.5 minutes of a video into: - an audio-only file (no video) Output goes into an 'extracted' folder next to this script. Requires: ffmpeg installed and on PATH. + +Usage: + python extract.py # normal extraction + python extract.py loud # loud-only mode: gate out quiet audio, keep only transients """ import subprocess @@ -13,6 +17,11 @@ import sys import shutil import json +# ─── Loud mode (pass "loud" as arg or env) ─── +LOUD_MODE = "loud" in sys.argv +if LOUD_MODE: + sys.argv.remove("loud") + # ─── Input / config (config.toml is base config, fallback to hardcoded) ─── CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.toml") _cfg_inputs = None @@ -307,12 +316,14 @@ def _parse_loudnorm_json(stderr: str) -> dict | None: return None def extract_audio_loudnorm(input_file: str, duration: int, start_sec: float, - out_path: str, stem: str, stream_idx: int | None = None) -> None: + out_path: str, stem: str, stream_idx: int | None = None, + loud_gate: bool = False) -> None: """Two-pass EBU R128 loudnorm extraction: measure then apply.""" ss_args = ["-ss", str(start_sec)] if start_sec and start_sec != 0 else [] map_args = ["-map", f"0:a:{stream_idx}"] if stream_idx is not None else [] base = ["ffmpeg", "-y", *ss_args, "-i", input_file, "-t", str(duration), "-vn", *map_args] measure_af = f"loudnorm=I={TARGET_LUFS}:TP={TARGET_TP}:LRA={TARGET_LRA}:print_format=json" + gate_filter = ",agate=threshold=0.02:ratio=20:attack=5:release=50" if loud_gate else "" # ─── Pass 1: measure ─── print(f"[loudness] {stem}: pass 1/2 measuring…") @@ -320,7 +331,10 @@ def extract_audio_loudnorm(input_file: str, duration: int, start_sec: float, r = subprocess.run(cmd_measure, capture_output=True, text=True) if r.returncode != 0: print(f"[loudness] {stem}: pass 1 failed ({r.stderr.strip()[-80:]}), falling back to simple extract", file=sys.stderr) - cmd_fallback = base + [*AUDIO_CODEC, out_path] + if loud_gate: + cmd_fallback = base + ["-af", f"agate=threshold=0.02:ratio=20:attack=5:release=50", *AUDIO_CODEC, out_path] + else: + cmd_fallback = base + [*AUDIO_CODEC, out_path] r2 = subprocess.run(cmd_fallback, capture_output=True, text=True) if r2.returncode != 0: sys.exit(f"ffmpeg (audio) failed:\n{r2.stderr}") @@ -329,7 +343,10 @@ def extract_audio_loudnorm(input_file: str, duration: int, start_sec: float, measured = _parse_loudnorm_json(r.stderr) if measured is None: print(f"[loudness] {stem}: could not parse measurement, falling back to simple extract", file=sys.stderr) - cmd_fallback = base + [*AUDIO_CODEC, out_path] + if loud_gate: + cmd_fallback = base + ["-af", f"agate=threshold=0.02:ratio=20:attack=5:release=50", *AUDIO_CODEC, out_path] + else: + cmd_fallback = base + [*AUDIO_CODEC, out_path] r2 = subprocess.run(cmd_fallback, capture_output=True, text=True) if r2.returncode != 0: sys.exit(f"ffmpeg (audio) failed:\n{r2.stderr}") @@ -346,7 +363,7 @@ def extract_audio_loudnorm(input_file: str, duration: int, start_sec: float, print(f"[loudness] {stem}: pass 2/2 applying loudnorm → {TARGET_LUFS} LUFS…") apply_af = (f"loudnorm=I={TARGET_LUFS}:TP={TARGET_TP}:LRA={TARGET_LRA}" f":measured_I={mi}:measured_TP={mtp}:measured_LRA={mlra}" - f":measured_thresh={mthresh}:offset={offset}:linear=true") + f":measured_thresh={mthresh}:offset={offset}:linear=true{gate_filter}") cmd_apply = base + ["-af", apply_af, *AUDIO_CODEC, out_path] r = subprocess.run(cmd_apply, capture_output=True, text=True) if r.returncode != 0: @@ -470,20 +487,22 @@ for _file_idx, INPUT_FILE in enumerate(INPUT_FILES): print(f"[audio: {stem}] skipped (no stream selected)") elif NORMALIZE: print(f"[audio: {stem}] extracting stream {audio_stream} with two-pass loudnorm…") - extract_audio_loudnorm(INPUT_FILE, DURATION, START_SEC, audio_out, stem, stream_idx=audio_stream) + extract_audio_loudnorm(INPUT_FILE, DURATION, START_SEC, audio_out, stem, + stream_idx=audio_stream, loud_gate=LOUD_MODE) else: map_args = ["-map", f"0:a:{audio_stream}"] - cmd_audio = [ - "ffmpeg", - "-y", - *ss_args, - "-i", INPUT_FILE, - "-t", str(DURATION), - "-vn", # no video - *map_args, # selected audio stream - *AUDIO_CODEC, # pcm_f32le 48kHz mono, CPU (no GPU equiv.) - audio_out - ] + if LOUD_MODE: + cmd_audio = [ + "ffmpeg", "-y", *ss_args, "-i", INPUT_FILE, "-t", str(DURATION), + "-vn", *map_args, "-af", + "agate=threshold=0.02:ratio=20:attack=5:release=50", + *AUDIO_CODEC, audio_out + ] + else: + cmd_audio = [ + "ffmpeg", "-y", *ss_args, "-i", INPUT_FILE, "-t", str(DURATION), + "-vn", *map_args, *AUDIO_CODEC, audio_out + ] print(f"[audio: {stem}] running ffmpeg (no normalization)…") r = subprocess.run(cmd_audio, capture_output=True, text=True) if r.returncode != 0: