diff --git a/config.toml b/config.toml index 3f6c32a..f828418 100644 --- a/config.toml +++ b/config.toml @@ -25,6 +25,8 @@ normalize = true target_lufs = -14 # -14 streaming, -23 broadcast target_tp = -1.0 # true peak limit (dBTP) target_lra = 11 # loudness range +# Audio stream selection: null = prompt interactively, int = use that index, "skip" = no audio +# stream = null [sync] # Analysis window: null = use full extracted duration; otherwise limit for quick test diff --git a/extract.py b/extract.py index 3328508..747fbab 100644 --- a/extract.py +++ b/extract.py @@ -25,6 +25,7 @@ _cfg_normalize = True _cfg_target_lufs = -14 _cfg_target_tp = -1.0 _cfg_target_lra = 11 +_cfg_audio_stream = None # None = prompt, int = index, "skip" = no audio extraction try: try: import tomllib # py3.11+ @@ -64,6 +65,12 @@ try: _cfg_target_lra = float(_au["target_lra"]) except Exception: 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: print(f"[config] loaded {CONFIG_PATH}: duration={_cfg_duration}s start={_cfg_start}s target_fps={_cfg_target_fps}") 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: 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: """Extract JSON block from loudnorm stderr output.""" try: @@ -160,10 +307,11 @@ 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) -> None: + out_path: str, stem: str, stream_idx: int | None = None) -> None: """Two-pass EBU R128 loudnorm extraction: measure then apply.""" 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" # ─── Pass 1: measure ─── @@ -245,7 +393,16 @@ for INPUT_FILE in INPUT_FILES: print(f"Audio → {audio_out}") 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) fps = probed.get(INPUT_FILE) needs_fps = not fps_matches(fps, TARGET_FPS_FLOAT) @@ -277,10 +434,13 @@ for INPUT_FILE in INPUT_FILES: print(f"[video: {stem}] done ✔") # ─── Extract audio-only (duration s, no video, CPU PCM re-encode) ─── - if NORMALIZE: - print(f"[audio: {stem}] extracting with two-pass loudnorm…") - extract_audio_loudnorm(INPUT_FILE, DURATION, START_SEC, audio_out, stem) + if audio_stream is None: + 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) else: + map_args = ["-map", f"0:a:{audio_stream}"] cmd_audio = [ "ffmpeg", "-y", @@ -288,6 +448,7 @@ for INPUT_FILE in INPUT_FILES: "-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 ]