feat: let user choose which audiostream to extract
This commit is contained in:
1 parent
c1856295e2
commit
2d957a465a
2 files changed
+169
-6
No files matched your search
@@ -25,6 +25,8 @@ normalize = true
|
|||||||
target_lufs = -14 # -14 streaming, -23 broadcast
|
target_lufs = -14 # -14 streaming, -23 broadcast
|
||||||
target_tp = -1.0 # true peak limit (dBTP)
|
target_tp = -1.0 # true peak limit (dBTP)
|
||||||
target_lra = 11 # loudness range
|
target_lra = 11 # loudness range
|
||||||
|
# Audio stream selection: null = prompt interactively, int = use that index, "skip" = no audio
|
||||||
|
# stream = null
|
||||||
|
|
||||||
[sync]
|
[sync]
|
||||||
# Analysis window: null = use full extracted duration; otherwise limit for quick test
|
# Analysis window: null = use full extracted duration; otherwise limit for quick test
|
||||||
|
|||||||
+167
-6
@@ -25,6 +25,7 @@ _cfg_normalize = True
|
|||||||
_cfg_target_lufs = -14
|
_cfg_target_lufs = -14
|
||||||
_cfg_target_tp = -1.0
|
_cfg_target_tp = -1.0
|
||||||
_cfg_target_lra = 11
|
_cfg_target_lra = 11
|
||||||
|
_cfg_audio_stream = None # None = prompt, int = index, "skip" = no audio extraction
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
import tomllib # py3.11+
|
import tomllib # py3.11+
|
||||||
@@ -64,6 +65,12 @@ try:
|
|||||||
_cfg_target_lra = float(_au["target_lra"])
|
_cfg_target_lra = float(_au["target_lra"])
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
if _au.get("stream") is not None:
|
||||||
|
val = _au["stream"]
|
||||||
|
if isinstance(val, str) and val == "skip":
|
||||||
|
_cfg_audio_stream = "skip"
|
||||||
|
elif isinstance(val, (int, float)):
|
||||||
|
_cfg_audio_stream = int(val)
|
||||||
if _cfg_inputs:
|
if _cfg_inputs:
|
||||||
print(f"[config] loaded {CONFIG_PATH}: duration={_cfg_duration}s start={_cfg_start}s target_fps={_cfg_target_fps}")
|
print(f"[config] loaded {CONFIG_PATH}: duration={_cfg_duration}s start={_cfg_start}s target_fps={_cfg_target_fps}")
|
||||||
except Exception as _e:
|
except Exception as _e:
|
||||||
@@ -150,6 +157,146 @@ def probe_fps(path: str) -> float | None:
|
|||||||
def fps_matches(a: float | None, b: float, eps: float = FPS_EPS) -> bool:
|
def fps_matches(a: float | None, b: float, eps: float = FPS_EPS) -> bool:
|
||||||
return a is not None and abs(a - b) < eps
|
return a is not None and abs(a - b) < eps
|
||||||
|
|
||||||
|
def probe_audio_streams(path: str) -> list[dict]:
|
||||||
|
"""Probe all audio streams in a file. Returns list of dicts matching ~/.bin/ff style."""
|
||||||
|
if shutil.which("ffprobe") is None:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
["ffprobe", "-v", "error", "-select_streams", "a",
|
||||||
|
"-show_entries",
|
||||||
|
"stream=index,codec_name,channels,sample_rate,bit_rate,bits_per_sample,duration,"
|
||||||
|
"stream_disposition=forced,default:"
|
||||||
|
"stream_tags=language,title",
|
||||||
|
"-of", "json", path],
|
||||||
|
capture_output=True, text=True, timeout=10
|
||||||
|
)
|
||||||
|
if r.returncode != 0:
|
||||||
|
return []
|
||||||
|
data = json.loads(r.stdout)
|
||||||
|
streams = []
|
||||||
|
for s in data.get("streams", []):
|
||||||
|
tags = {k.lower(): v for k, v in (s.get("tags") or {}).items()}
|
||||||
|
dispo = s.get("disposition", {})
|
||||||
|
sr = s.get("sample_rate")
|
||||||
|
br = s.get("bit_rate")
|
||||||
|
depth = s.get("bits_per_sample") or s.get("bits_per_raw_sample")
|
||||||
|
dur = s.get("duration")
|
||||||
|
streams.append({
|
||||||
|
"index": s.get("index", 0),
|
||||||
|
"codec": s.get("codec_name", ""),
|
||||||
|
"channels": s.get("channels", 0),
|
||||||
|
"sample_rate": int(sr) if sr else 0,
|
||||||
|
"sample_rate_khz": f"{int(sr)/1000:.1f} kHz" if sr else "",
|
||||||
|
"bit_rate": f"{int(br)//1000} kbps" if br else "",
|
||||||
|
"bit_depth": f"{depth}-bit" if depth else "",
|
||||||
|
"duration": dur,
|
||||||
|
"language": _normalize_audio_language(tags.get("language", "und")),
|
||||||
|
"title": tags.get("title", ""),
|
||||||
|
"is_default": dispo.get("default", 0) == 1,
|
||||||
|
"is_forced": dispo.get("forced", 0) == 1,
|
||||||
|
})
|
||||||
|
return streams
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _normalize_audio_language(raw: str) -> str:
|
||||||
|
"""Map common ISO-639 codes to readable names."""
|
||||||
|
lang_map = {
|
||||||
|
"eng": "English", "ger": "German", "deu": "German",
|
||||||
|
"fre": "French", "fra": "French", "spa": "Spanish",
|
||||||
|
"ita": "Italian", "jpn": "Japanese", "chi": "Chinese",
|
||||||
|
"zho": "Chinese", "kor": "Korean", "por": "Portuguese",
|
||||||
|
"rus": "Russian", "ara": "Arabic", "hin": "Hindi",
|
||||||
|
"dut": "Dutch", "nld": "Dutch", "swe": "Swedish",
|
||||||
|
"nor": "Norwegian", "dan": "Danish", "fin": "Finnish",
|
||||||
|
"pol": "Polish", "tur": "Turkish", "tha": "Thai",
|
||||||
|
"vie": "Vietnamese", "und": "Undetermined", "mis": "Misnamed",
|
||||||
|
"inl": "Latin American Spanish", "qaa": "Reserved",
|
||||||
|
}
|
||||||
|
raw_lower = raw.lower().strip()
|
||||||
|
return lang_map.get(raw_lower, raw_lower.upper())
|
||||||
|
|
||||||
|
def select_audio_stream(path: str, cfg_hint: int | str | None = None) -> int | None:
|
||||||
|
"""
|
||||||
|
Probe audio streams and let user pick one.
|
||||||
|
Returns stream index (int) or None (no audio).
|
||||||
|
cfg_hint: int = use that index directly, "skip" = None, None = prompt.
|
||||||
|
"""
|
||||||
|
streams = probe_audio_streams(path)
|
||||||
|
|
||||||
|
if not streams:
|
||||||
|
print(f"[audio] {os.path.basename(path)}: no audio streams found")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if len(streams) == 1:
|
||||||
|
s = streams[0]
|
||||||
|
label = _format_stream_label(s)
|
||||||
|
print(f"[audio] {os.path.basename(path)}: only 1 stream → {label}")
|
||||||
|
return s["index"]
|
||||||
|
|
||||||
|
# Multiple streams — show menu
|
||||||
|
print(f"\n{'=' * 60}")
|
||||||
|
print(f"Audio streams in: {os.path.basename(path)}")
|
||||||
|
print(f"{'=' * 60}")
|
||||||
|
for i, s in enumerate(streams):
|
||||||
|
label = _format_stream_label(s)
|
||||||
|
marker = " *" if s.get("is_default") else ""
|
||||||
|
print(f" [{i}] stream {s['index']}: {label}{marker}")
|
||||||
|
print(f" [s] skip audio extraction")
|
||||||
|
print(f"{'=' * 60}")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
choice = input("Select audio stream [0]: ").strip().lower()
|
||||||
|
if choice == "" or choice == "0":
|
||||||
|
return streams[0]["index"]
|
||||||
|
if choice == "s":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
idx = int(choice)
|
||||||
|
if 0 <= idx < len(streams):
|
||||||
|
return streams[idx]["index"]
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
print(f" Invalid choice, enter 0-{len(streams)-1} or 's'")
|
||||||
|
|
||||||
|
def _format_stream_label(s: dict) -> str:
|
||||||
|
parts = []
|
||||||
|
# Language + title first (most important)
|
||||||
|
lang = s.get("language", "")
|
||||||
|
title = s.get("title", "")
|
||||||
|
if lang:
|
||||||
|
parts.append(lang)
|
||||||
|
if title:
|
||||||
|
parts.append(f'"{title}"')
|
||||||
|
# Codec
|
||||||
|
codec = s.get("codec", "")
|
||||||
|
if codec:
|
||||||
|
parts.append(codec.upper())
|
||||||
|
# Specs: channels, sample rate, bit depth
|
||||||
|
specs = []
|
||||||
|
ch = s.get("channels", 0)
|
||||||
|
if ch:
|
||||||
|
specs.append(f"{ch}ch")
|
||||||
|
if s.get("sample_rate_khz"):
|
||||||
|
specs.append(s["sample_rate_khz"])
|
||||||
|
if s.get("bit_depth"):
|
||||||
|
specs.append(s["bit_depth"])
|
||||||
|
if specs:
|
||||||
|
parts.append(f"({', '.join(specs)})")
|
||||||
|
# Bitrate
|
||||||
|
if s.get("bit_rate"):
|
||||||
|
parts.append(f"@{s['bit_rate']}")
|
||||||
|
# Flags
|
||||||
|
flags = []
|
||||||
|
if s.get("is_default"):
|
||||||
|
flags.append("Default")
|
||||||
|
if s.get("is_forced"):
|
||||||
|
flags.append("Forced")
|
||||||
|
if flags:
|
||||||
|
parts.append(f"[{'/'.join(flags)}]")
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
def _parse_loudnorm_json(stderr: str) -> dict | None:
|
def _parse_loudnorm_json(stderr: str) -> dict | None:
|
||||||
"""Extract JSON block from loudnorm stderr output."""
|
"""Extract JSON block from loudnorm stderr output."""
|
||||||
try:
|
try:
|
||||||
@@ -160,10 +307,11 @@ def _parse_loudnorm_json(stderr: str) -> dict | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def extract_audio_loudnorm(input_file: str, duration: int, start_sec: float,
|
def extract_audio_loudnorm(input_file: str, duration: int, start_sec: float,
|
||||||
out_path: str, stem: str) -> None:
|
out_path: str, stem: str, stream_idx: int | None = None) -> None:
|
||||||
"""Two-pass EBU R128 loudnorm extraction: measure then apply."""
|
"""Two-pass EBU R128 loudnorm extraction: measure then apply."""
|
||||||
ss_args = ["-ss", str(start_sec)] if start_sec and start_sec != 0 else []
|
ss_args = ["-ss", str(start_sec)] if start_sec and start_sec != 0 else []
|
||||||
base = ["ffmpeg", "-y", *ss_args, "-i", input_file, "-t", str(duration), "-vn"]
|
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"
|
measure_af = f"loudnorm=I={TARGET_LUFS}:TP={TARGET_TP}:LRA={TARGET_LRA}:print_format=json"
|
||||||
|
|
||||||
# ─── Pass 1: measure ───
|
# ─── Pass 1: measure ───
|
||||||
@@ -245,7 +393,16 @@ for INPUT_FILE in INPUT_FILES:
|
|||||||
print(f"Audio → {audio_out}")
|
print(f"Audio → {audio_out}")
|
||||||
print(f"-" * 50)
|
print(f"-" * 50)
|
||||||
|
|
||||||
# ─── Extract video-only (first 90s, no audio, CPU FFV1 re-encode) ───
|
# ─── Probe and select audio stream ───
|
||||||
|
audio_stream = None
|
||||||
|
if not (_cfg_audio_stream == "skip"):
|
||||||
|
audio_stream = select_audio_stream(INPUT_FILE, cfg_hint=_cfg_audio_stream)
|
||||||
|
if audio_stream is not None:
|
||||||
|
print(f"[audio] {stem}: selected stream {audio_stream}")
|
||||||
|
else:
|
||||||
|
print(f"[audio] {stem}: no audio selected, skipping audio extraction")
|
||||||
|
|
||||||
|
# ─── Extract video-only (duration s, no audio, CPU FFV1 re-encode) ───
|
||||||
# Only apply fps filter if probed fps differs from target (avoid needless re-timing)
|
# Only apply fps filter if probed fps differs from target (avoid needless re-timing)
|
||||||
fps = probed.get(INPUT_FILE)
|
fps = probed.get(INPUT_FILE)
|
||||||
needs_fps = not fps_matches(fps, TARGET_FPS_FLOAT)
|
needs_fps = not fps_matches(fps, TARGET_FPS_FLOAT)
|
||||||
@@ -277,10 +434,13 @@ for INPUT_FILE in INPUT_FILES:
|
|||||||
print(f"[video: {stem}] done ✔")
|
print(f"[video: {stem}] done ✔")
|
||||||
|
|
||||||
# ─── Extract audio-only (duration s, no video, CPU PCM re-encode) ───
|
# ─── Extract audio-only (duration s, no video, CPU PCM re-encode) ───
|
||||||
if NORMALIZE:
|
if audio_stream is None:
|
||||||
print(f"[audio: {stem}] extracting with two-pass loudnorm…")
|
print(f"[audio: {stem}] skipped (no stream selected)")
|
||||||
extract_audio_loudnorm(INPUT_FILE, DURATION, START_SEC, audio_out, stem)
|
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)
|
||||||
else:
|
else:
|
||||||
|
map_args = ["-map", f"0:a:{audio_stream}"]
|
||||||
cmd_audio = [
|
cmd_audio = [
|
||||||
"ffmpeg",
|
"ffmpeg",
|
||||||
"-y",
|
"-y",
|
||||||
@@ -288,6 +448,7 @@ for INPUT_FILE in INPUT_FILES:
|
|||||||
"-i", INPUT_FILE,
|
"-i", INPUT_FILE,
|
||||||
"-t", str(DURATION),
|
"-t", str(DURATION),
|
||||||
"-vn", # no video
|
"-vn", # no video
|
||||||
|
*map_args, # selected audio stream
|
||||||
*AUDIO_CODEC, # pcm_f32le 48kHz mono, CPU (no GPU equiv.)
|
*AUDIO_CODEC, # pcm_f32le 48kHz mono, CPU (no GPU equiv.)
|
||||||
audio_out
|
audio_out
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in new issue
Block a user