From 10f03ec6d3492de6bb37f269bdbd16d7b5f97b7b Mon Sep 17 00:00:00 2001 From: Honney OC Date: Sun, 13 Sep 2026 22:54:54 +0000 Subject: [PATCH] feat: QMediaPlayer video playback and whisper audio rename tool --- Makefile | 4 +- rename_audio.py | 403 ++++++++++++++++++++++++++++++++++++++++++++++++ verify_sync.py | 309 ++++++++++++++----------------------- 3 files changed, 523 insertions(+), 193 deletions(-) create mode 100644 rename_audio.py diff --git a/Makefile b/Makefile index 9551fea..967df04 100644 --- a/Makefile +++ b/Makefile @@ -51,8 +51,8 @@ install: fi @echo "=> installing common deps..." @$(PIP_INSTALL) soundfile scipy numpy pyqtgraph PyQt6 || echo " common deps failed" - @echo "=> optional sounddevice (audio playback, volume slider)..." - @$(PIP_INSTALL) sounddevice || echo " sounddevice optional failed (PortAudio missing) -> playback disabled" + @echo "=> optional: sounddevice (playback), openai-whisper (language detection)..." + @$(PIP_INSTALL) sounddevice openai-whisper || echo " optional deps failed (sounddevice/whisper)" @$(PY) -c "import torch, sys; print(f'torch {torch.__version__} cuda_available={torch.cuda.is_available()}', end=''); print(f' device={torch.cuda.get_device_name(0)}' if torch.cuda.is_available() else ' (cpu)')" 2>&1 || echo "torch not available, using numpy CPU fallback (still functional for 90s xcorr)" @echo "=> done. Activate: source $(VENV)/bin/activate" diff --git a/rename_audio.py b/rename_audio.py new file mode 100644 index 0000000..00a5420 --- /dev/null +++ b/rename_audio.py @@ -0,0 +1,403 @@ +#!/usr/bin/env python3 +""" +Detect audio stream languages via Whisper and set MKV metadata. + +Usage: + python rename_audio.py /path/to/movie.mkv + python rename_audio.py /path/to/movie.mkv --apply + python rename_audio.py /path/to/movie.mkv --apply --stream 0,2 + +Features: + - Probes all audio streams in the file + - Runs Whisper language detection on each (showing confidence scores) + - Detects uncertain results (low confidence / top language < 80%) + - Sets language code + updates title with detected language name + - No re-encode — metadata only via ffmpeg -map_metadata + -metadata + +Requires: pip install openai-whisper (or faster-whisper) +""" + +from __future__ import annotations +import argparse +import json +import os +import shutil +import subprocess +import sys +import tempfile +import numpy as np + +try: + import whisper + HAS_WHISPER = True +except ImportError: + HAS_WHISPER = False + + +# ─── Language code ↔ name mapping (ISO 639-1 / ISO 639-2 for MKV) ─── + +LANG_NAMES = { + "en": "English", "de": "German", "fr": "French", "es": "Spanish", + "it": "Italian", "pt": "Portuguese", "nl": "Dutch", "ru": "Russian", + "ja": "Japanese", "ko": "Korean", "zh": "Chinese", "ar": "Arabic", + "hi": "Hindi", "tr": "Turkish", "pl": "Polish", "sv": "Swedish", + "da": "Danish", "no": "Norwegian", "fi": "Finnish", "cs": "Czech", + "ro": "Romanian", "hu": "Hungarian", "uk": "Ukrainian", "el": "Greek", + "he": "Hebrew", "th": "Thai", "vi": "Vietnamese", "id": "Indonesian", + "ms": "Malay", "tl": "Filipino", "ca": "Catalan", "eu": "Basque", + "gl": "Galician", "af": "Afrikaans", "bg": "Bulgarian", "hr": "Croatian", + "sk": "Slovak", "sl": "Slovenian", "sr": "Serbian", "lt": "Lithuanian", + "lv": "Latvian", "et": "Estonian", "sw": "Swahili", "ta": "Tamil", + "te": "Telugu", "bn": "Bengali", "ur": "Urdu", "fa": "Persian", + "ga": "Irish", "cy": "Welsh", "is": "Icelandic", "mk": "Macedonian", + "sq": "Albanian", "bs": "Bosnian", "mt": "Maltese", "hy": "Armenian", + "ka": "Georgian", "km": "Khmer", "my": "Burmese", "lo": "Lao", + "ne": "Nepali", "si": "Sinhala", "kk": "Kazakh", "uz": "Uzbek", + "mn": "Mongolian", "ps": "Pashto", "sd": "Sindhi", "gu": "Gujarati", + "pa": "Punjabi", "mr": "Marathi", "kn": "Kannada", "ml": "Malayalam", + "or": "Odia", "as": "Assamese", "ha": "Hausa", "yo": "Yoruba", + "zu": "Zulu", "ig": "Igbo", "so": "Somali", "am": "Amharic", + "rw": "Kinyarwanda", "sn": "Shona", "mg": "Malagasy", "wo": "Wolof", + "ny": "Chichewa", "st": "Southern Sotho", "tn": "Tswana", + "ts": "Tsonga", "ve": "Venda", "xh": "Xhosa", "ab": "Abkhaz", + "aa": "Afar", "ak": "Akan", "an": "Aragonese", "as": "Assamese", + "av": "Avaric", "ay": "Aymara", "az": "Azerbaijani", "be": "Belarusian", + "bh": "Bihari", "bi": "Bislama", "bm": "Bambara", "bo": "Tibetan", + "br": "Breton", "ch": "Chamorro", "co": "Corsican", "cr": "Cree", + "cu": "Old Church Slavonic", "cv": "Chuvash", "dv": "Divehi", + "dz": "Dzongkha", "ee": "Ewe", "fo": "Faroese", "fj": "Fijian", + "fy": "Western Frisian", "gd": "Scottish Gaelic", "gn": "Guarani", + "gv": "Manx", "ht": "Haitian", "hz": "Herero", "ia": "Interlingua", + "ie": "Interlingue", "ik": "Inupiaq", "io": "Ido", "jv": "Javanese", + "ka": "Georgian", "kg": "Kongo", "ki": "Kikuyu", "kj": "Kwanyama", + "kl": "Kalaallisut", "kr": "Kanuri", "ku": "Kurdish", "ky": "Kyrgyz", + "lb": "Luxembourgish", "lg": "Ganda", "li": "Limburgish", "ln": "Lingala", + "lt": "Lithuanian", "lu": "Luba-Katanga", "lv": "Latvian", + "mg": "Malagasy", "mh": "Marshall", "mi": "Maori", "mk": "Macedonian", + "mn": "Mongolian", "mo": "Moldavian", "mr": "Marathi", "ms": "Malay", + "mt": "Maltese", "na": "Nauru", "nb": "Norwegian Bokmal", + "nd": "North Ndebele", "ne": "Nepali", "ng": "Ndonga", + "nn": "Norwegian Nynorsk", "nr": "South Ndebele", "nv": "Navajo", + "oc": "Occitan", "oj": "Ojibwa", "om": "Oromo", "or": "Odia", + "os": "Ossetic", "pi": "Pali", "ps": "Pashto", "qu": "Quechua", + "rm": "Romansh", "rn": "Rundi", "rw": "Kinyarwanda", + "sa": "Sanskrit", "sc": "Sardinian", "se": "Northern Sami", + "sg": "Sango", "si": "Sinhala", "sm": "Samoan", "sn": "Shona", + "sd": "Sindhi", "so": "Somali", "sr": "Serbian", "ss": "Swati", + "su": "Sundanese", "sw": "Swahili", "ty": "Tahitian", + "ug": "Uyghur", "uz": "Uzbek", "ve": "Venda", "vo": "Volapuk", + "wa": "Walloon", "wo": "Wolof", "xh": "Xhosa", "yi": "Yiddish", + "yo": "Yoruba", "za": "Zhuang", "zu": "Zulu", +} + +# ISO 639-2/B codes for MKV (3-letter) +LANG_CODE_TO_3 = { + "en": "eng", "de": "ger", "fr": "fre", "es": "spa", "it": "ita", + "pt": "por", "nl": "dut", "ru": "rus", "ja": "jpn", "ko": "kor", + "zh": "chi", "ar": "ara", "hi": "hin", "tr": "tur", "pl": "pol", + "sv": "swe", "da": "dan", "no": "nor", "fi": "fin", "cs": "cze", + "ro": "rum", "hu": "hun", "uk": "ukr", "el": "gre", "he": "heb", + "th": "tha", "vi": "vie", "id": "ind", "ms": "may", "tl": "fil", + "ca": "cat", "eu": "baq", "gl": "gal", "af": "afr", "bg": "bul", + "hr": "hrv", "sk": "slo", "sl": "slv", "sr": "srp", "lt": "lit", + "lv": "lav", "et": "est", "sw": "swa", "ta": "tam", "te": "tel", + "bn": "ben", "ur": "urd", "fa": "per", "ga": "gle", "cy": "wel", + "is": "ice", "mk": "mac", "sq": "alb", "bs": "bos", "mt": "mlt", + "hy": "arm", "ka": "geo", "km": "khm", "my": "bur", "lo": "lao", + "ne": "nep", "si": "sin", "kk": "kaz", "uz": "uzb", "mn": "mon", + "ps": "pus", "sd": "snd", "gu": "guj", "pa": "pan", "as": "asm", + "ha": "hau", "yo": "yor", "zu": "zul", "ig": "ibo", "so": "som", + "am": "amh", "rw": "kin", "sn": "sna", "ny": "nya", "wo": "wol", +} + + +def probe_audio_streams(path: str) -> list[dict]: + """Probe all audio streams in a file.""" + try: + r = subprocess.run( + ["ffprobe", "-v", "error", "-select_streams", "a", + "-show_entries", + "stream=index,codec_name,channels,sample_rate,bit_rate," + "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()} + sr = s.get("sample_rate") + 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, + "language": tags.get("language", ""), + "title": tags.get("title", ""), + }) + return streams + except Exception: + return [] + + +def extract_audio_segment(video_path: str, stream_index: int, out_wav: str, + start_sec: float = 0, duration_sec: float = 60) -> bool: + """Extract a segment of an audio stream for language detection.""" + try: + r = subprocess.run( + ["ffmpeg", "-y", + "-ss", str(start_sec), + "-i", video_path, + "-map", f"0:a:{stream_index}", + "-t", str(duration_sec), + "-acodec", "pcm_f32le", "-ar", "16000", "-ac", "1", + "-v", "error", out_wav], + capture_output=True, timeout=30 + ) + return r.returncode == 0 and os.path.exists(out_wav) and os.path.getsize(out_wav) > 0 + except Exception: + return False + + +def detect_language_whisper(wav_path: str, model_name: str = "base") -> tuple[str, float, dict]: + """ + Detect language using Whisper. Returns (lang_code, top_probability, full_probs). + """ + if not HAS_WHISPER: + raise RuntimeError("openai-whisper not installed. Run: pip install openai-whisper") + + model = whisper.load_model(model_name) + audio = whisper.load_audio(wav_path) + # trim to 30 seconds max for speed + audio = audio[:30 * 16000] + + result = model.detect_language(audio) + lang = result["language"] + probs = result.get("probabilities", {}) + + # Build full probability dict + full_probs = {} + if probs is not None: + for i, p in enumerate(probs): + lang_token = whisper.tokenizer._lang_code_to_token.get(i, "") + if lang_token: + full_probs[lang_token] = float(p) + + # Top probability + top_prob = full_probs.get(lang, 0.0) + return lang, top_prob, full_probs + + +def get_lang_name(code: str) -> str: + """Get human-readable language name from ISO 639-1 code.""" + return LANG_NAMES.get(code, code.upper()) + + +def apply_metadata(file_path: str, stream_idx: int, lang_code_3: str, + title: str, dry_run: bool = True) -> str: + """ + Apply language metadata to a specific audio stream. + Returns the ffmpeg command (for display) or runs it. + """ + # Use 3-letter ISO 639-2/B code for MKV + meta_key = f"metadata:s:a:{stream_idx}" + + cmd = [ + "ffmpeg", "-y", + "-i", file_path, + "-map", "0", + "-c", "copy", + f"{meta_key}:language={lang_code_3}", + f"{meta_key}:title={title}", + ] + + if dry_run: + return " ".join(cmd) + + # Write to temp file, then replace + base, ext = os.path.splitext(file_path) + tmp_out = f"{tmp_out_base}{ext}" + cmd.append(tmp_out) + + r = subprocess.run(cmd, capture_output=True, timeout=300) + if r.returncode != 0: + print(f" ERROR: ffmpeg metadata failed: {r.stderr.decode()[-200:]}", file=sys.stderr) + return "" + + # Replace original + shutil.move(tmp_out, file_path) + return file_path + + +def main(): + ap = argparse.ArgumentParser(description="Detect audio languages via Whisper and set MKV metadata") + ap.add_argument("file", help="MKV/MP4 file to process") + ap.add_argument("--apply", action="store_true", help="Actually apply changes (default: dry run)") + ap.add_argument("--stream", default=None, help="Process specific stream indices (comma-separated, e.g. 0,2)") + ap.add_argument("--model", default="base", help="Whisper model (tiny/base/small/medium/large)") + ap.add_argument("--segment", type=float, default=60, help="Seconds of audio to analyze (default: 60)") + ap.add_argument("--start", type=float, default=0, help="Start offset for analysis (seconds)") + args = ap.parse_args() + + if not os.path.exists(args.file): + print(f"ERROR: file not found: {args.file}", file=sys.stderr) + sys.exit(1) + + if not HAS_WHISPER: + print("ERROR: openai-whisper not installed.", file=sys.stderr) + print("Install with: pip install openai-whisper", file=sys.stderr) + print("Or for faster detection: pip install faster-whisper", file=sys.stderr) + sys.exit(1) + + streams = probe_audio_streams(args.file) + if not streams: + print("No audio streams found.", file=sys.stderr) + sys.exit(1) + + print(f"File: {args.file}") + print(f"Audio streams: {len(streams)}") + print() + + # Filter streams if --stream specified + if args.stream: + selected = [int(x.strip()) for x in args.stream.split(",")] + streams = [s for s in streams if s["index"] in selected] + + # Create temp dir for extracted segments + with tempfile.TemporaryDirectory(prefix="rename_audio_") as tmp_dir: + changes = [] + + for s in streams: + idx = s["index"] + cur_lang = s["language"] + cur_title = s["title"] + print(f"{'=' * 60}") + print(f"Stream [{idx}]: {s['codec'].upper()} {s['channels']}ch") + print(f" Current language: {cur_lang or '(none)'}") + print(f" Current title: {cur_title or '(none)'}") + + # Extract audio segment + wav_path = os.path.join(tmp_dir, f"stream_{idx}.wav") + print(f" Extracting {args.segment}s from {args.start}s...") + if not extract_audio_segment(args.file, idx, wav_path, args.start, args.segment): + print(f" SKIP: failed to extract audio", file=sys.stderr) + continue + + # Detect language + print(f" Detecting language with Whisper ({args.model})...") + try: + lang, top_prob, all_probs = detect_language_whisper(wav_path, args.model) + except Exception as e: + print(f" ERROR: detection failed: {e}", file=sys.stderr) + continue + + lang_name = get_lang_name(lang) + print(f"\n Detected: {lang_name} ({lang}) — confidence: {top_prob:.1%}") + + # Show top 5 probabilities + sorted_probs = sorted(all_probs.items(), key=lambda x: -x[1])[:5] + print(f" Top languages:") + for l, p in sorted_probs: + marker = " <<<" if l == lang else "" + print(f" {get_lang_name(l):20s} ({l}) {p:.1%}{marker}") + + # Confidence assessment + if top_prob >= 0.80: + confidence = "HIGH" + elif top_prob >= 0.50: + confidence = "MEDIUM (review recommended)" + else: + confidence = "LOW (likely unsupported language)" + print(f" Confidence: {confidence}") + + # Determine new metadata + lang_3 = LANG_CODE_TO_3.get(lang, lang) + needs_change = False + + if cur_lang != lang_3: + needs_change = True + print(f"\n → Language will change: {cur_lang or '(none)'} → {lang_3}") + else: + print(f"\n → Language already correct: {lang_3}") + + # Build new title: prepend detected language name if not already present + new_title = cur_title + if lang_name.lower() not in (cur_title or "").lower(): + if cur_title: + new_title = f"{lang_name} {cur_title}" + else: + new_title = lang_name + if new_title != cur_title: + needs_change = True + print(f" → Title will change: \"{cur_title or '(none)'}\" → \"{new_title}\"") + + if not needs_change: + print(f" → No changes needed") + continue + + # Build ffmpeg command + cmd_str = apply_metadata(args.file, idx, lang_3, new_title, dry_run=True) + print(f"\n Command: {cmd_str}") + + changes.append({ + "index": idx, + "old_lang": cur_lang, + "new_lang": lang_3, + "old_title": cur_title, + "new_title": new_title, + "detected": lang, + "confidence": top_prob, + "cmd": cmd_str, + }) + + print(f"\n{'=' * 60}") + if not changes: + print("No changes to apply.") + return + + print(f"Changes to apply: {len(changes)}") + for c in changes: + print(f" Stream [{c['index']}]: lang {c['old_lang'] or '(none)'} → {c['new_lang']}, " + f"title → \"{c['new_title']}\" ({c['confidence']:.0%} confidence)") + + if not args.apply: + print(f"\nDry run complete. Use --apply to apply changes.") + return + + # Apply changes + print(f"\nApplying changes...") + for c in changes: + lang_3 = c["new_lang"] + title = c["new_title"] + idx = c["index"] + + meta_key = f"metadata:s:a:{idx}" + base, ext = os.path.splitext(args.file) + tmp_out = f"{base}_tmp{ext}" + + cmd = [ + "ffmpeg", "-y", + "-i", args.file, + "-map", "0", + "-c", "copy", + f"{meta_key}:language={lang_3}", + f"{meta_key}:title={title}", + tmp_out + ] + + print(f" Stream [{idx}]: {' '.join(cmd[:8])}...") + r = subprocess.run(cmd, capture_output=True, timeout=300) + if r.returncode != 0: + print(f" ERROR: ffmpeg failed: {r.stderr.decode()[-200:]}", file=sys.stderr) + continue + + shutil.move(tmp_out, args.file) + print(f" ✓ Stream [{idx}] updated") + + print(f"\nDone. File: {args.file}") + + +if __name__ == "__main__": + main() diff --git a/verify_sync.py b/verify_sync.py index 797bb2e..2e0199f 100644 --- a/verify_sync.py +++ b/verify_sync.py @@ -1,18 +1,19 @@ #!/usr/bin/env python3 """ -Verify sync: video player + dual waveform display for A/B audio comparison. +Verify sync: QMediaPlayer video player + dual waveform display. Modes: --mode short : extracted 30s parts (video_a + wav_a + wav_b) --mode full : synced output MKV (extracts audio streams from it) Features: - - Video playback with timeline seek bar - - Audio playback: A only, B only, or A+B mix + - Smooth video playback via QMediaPlayer/QVideoWidget + - Audio playback: A only, B only, or A+B mix via sounddevice - Combined waveform overlay (A and B in different colors) - Two separate waveform graphs with playhead - - Zoomable waveforms (Ctrl+scroll) with linked x-axes - - Toggle A/B on/off, volume, play/pause + - Zoomable waveforms (scroll) with linked x-axes + - Timeline seek bar synced to video position + - Toggle A/B on/off, volume Usage: make verify LAG=0.042 @@ -31,7 +32,7 @@ except ModuleNotFoundError: try: import pyqtgraph as pg - from PyQt6 import QtWidgets, QtCore, QtGui + from PyQt6 import QtWidgets, QtCore, QtGui, QtMultimedia, QtMultimediaWidgets HAS_PG = True except ImportError as e: print("ERROR: pyqtgraph/PyQt6 not installed — run make install", file=sys.stderr) @@ -61,37 +62,7 @@ def load_config(path: str) -> dict: return tomllib.load(f) -def probe_video_info(path: str) -> dict: - try: - r = subprocess.run( - ["ffprobe", "-v", "error", "-select_streams", "v:0", - "-show_entries", "stream=width,height,r_frame_rate,duration", - "-show_entries", "format=duration", - "-of", "json", path], - capture_output=True, text=True, timeout=10 - ) - data = json.loads(r.stdout) - stream = data.get("streams", [{}])[0] - fmt = data.get("format", {}) - fps_str = stream.get("r_frame_rate", "24000/1001") - if "/" in fps_str: - num, den = fps_str.split("/") - fps = float(num) / float(den) - else: - fps = float(fps_str) - duration = float(fmt.get("duration", stream.get("duration", 0))) - return { - "width": int(stream.get("width", 1920)), - "height": int(stream.get("height", 1080)), - "fps": fps, - "duration": duration, - } - except Exception: - return {"width": 1920, "height": 1080, "fps": 24000/1001, "duration": 0} - - def probe_audio_streams(path: str) -> list[dict]: - """Probe audio streams for full mode.""" try: r = subprocess.run( ["ffprobe", "-v", "error", "-select_streams", "a", @@ -113,7 +84,7 @@ def probe_audio_streams(path: str) -> list[dict]: "codec": s.get("codec_name", ""), "channels": s.get("channels", 0), "sample_rate": int(sr) if sr else 0, - "language": tags.get("language", "und"), + "language": tags.get("language", ""), "title": tags.get("title", ""), }) return streams @@ -122,7 +93,6 @@ def probe_audio_streams(path: str) -> list[dict]: def extract_audio_from_mkv(mkv_path: str, stream_index: int, out_wav: str, sr: int = 48000) -> bool: - """Extract a single audio stream from MKV to mono WAV.""" try: r = subprocess.run( ["ffmpeg", "-y", "-i", mkv_path, @@ -136,32 +106,7 @@ def extract_audio_from_mkv(mkv_path: str, stream_index: int, out_wav: str, sr: i return False -def extract_frame(video_path: str, timestamp: float, width: int, height: int) -> bytes | None: - """Extract a single frame at timestamp as raw RGB bytes.""" - try: - r = subprocess.run( - ["ffmpeg", "-ss", f"{timestamp:.4f}", "-i", video_path, - "-vframes", "1", "-vf", f"scale={width}:{height}", - "-f", "rawvideo", "-pix_fmt", "rgb24", - "-v", "error", "-"], - capture_output=True, timeout=5 - ) - if r.returncode != 0 or not r.stdout: - return None - return r.stdout - except Exception: - return None - - -def raw_to_qimage(raw: bytes, width: int, height: int) -> QtGui.QImage | None: - if len(raw) < width * height * 3: - return None - img = QtGui.QImage(raw, width, height, width * 3, QtGui.QImage.Format.Format_RGB888) - return img.copy() - - def apply_lag(a: np.ndarray, b: np.ndarray, lag_samples: int) -> tuple[np.ndarray, np.ndarray]: - """Align A and B with lag applied (B delayed by lag_samples).""" if lag_samples >= 0: a_out = a[lag_samples:] b_out = np.concatenate([np.zeros(lag_samples, dtype=np.float32), b]) @@ -221,36 +166,13 @@ class AudioPlayer: self.playing = False -# ─── Zoomable plot widget ─── - -class ZoomPlot(pg.PlotWidget): - """PlotWidget with Ctrl+scroll zoom on x-axis, right-drag to pan.""" - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.setMouseEnabled(x=True, y=False) - # Enable wheel zoom on x-axis only - self.getViewBox().setMouseEnabled(x=True, y=False) - - def wheelEvent(self, event): - delta = event.angleDelta().y() - factor = 1.1 if delta > 0 else 1 / 1.1 - vb = self.getViewBox() - # zoom x-axis around cursor - mouse_pos = vb.mapSceneToView(event.position()) - x_range = vb.viewRange()[0] - new_width = (x_range[1] - x_range[0]) / factor - center = mouse_pos.x() - vb.setXRange(center - new_width / 2, center + new_width / 2, padding=0) - event.accept() - - # ─── Main Window ─── class VerifyWindow(QtWidgets.QMainWindow): def __init__(self, video_path: str, wav_a_path: str, wav_b_path: str, sr: int = 48000, lag_sec: float = 0.0, mode: str = "short", out_dir: str = "output", - temp_dir: str | None = None): + temp_dir: str | None = None, volume: int = 70): super().__init__() self.video_path = video_path self.wav_a_path = wav_a_path @@ -261,7 +183,10 @@ class VerifyWindow(QtWidgets.QMainWindow): self.mode = mode self.out_dir = out_dir self.temp_dir = temp_dir - self.audio = AudioPlayer() + self.audio_player = AudioPlayer() + self.default_volume = volume + self.duration = 0.0 + self.playing_a_b = False # our custom audio mix playing title = "verify_sync" if mode == "short": @@ -282,26 +207,11 @@ class VerifyWindow(QtWidgets.QMainWindow): self.a_aligned, self.b_aligned = apply_lag(self.a_orig, self.b_orig, self.lag_samples) self.duration = len(self.a_aligned) / sr - # Video info - vinfo = probe_video_info(video_path) - self.v_width = vinfo["width"] - self.v_height = vinfo["height"] - self.v_fps = vinfo["fps"] - self.v_duration = vinfo["duration"] if vinfo["duration"] > 0 else self.duration - - # Playback state - self.playing = False - self.play_start_time = 0.0 - self.play_offset = 0.0 - self.timer = QtCore.QTimer() - self.timer.setInterval(33) - self.timer.timeout.connect(self._on_tick) - self._seeking = False - self._last_frame_t = -1.0 - self._setup_ui() self._draw_waveforms() - self._update_frame(0.0) + + # Set up QMediaPlayer for video + self._setup_video_player() def _setup_ui(self): central = QtWidgets.QWidget() @@ -309,14 +219,13 @@ class VerifyWindow(QtWidgets.QMainWindow): lay = QtWidgets.QVBoxLayout(central) lay.setSpacing(2) - # ── Video display ── - self.video_label = QtWidgets.QLabel() - self.video_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) - self.video_label.setMinimumHeight(300) - self.video_label.setStyleSheet("background: #000;") - lay.addWidget(self.video_label, stretch=4) + # ── Video display (QVideoWidget) ── + self.video_widget = QtMultimediaWidgets.QVideoWidget() + self.video_widget.setMinimumHeight(300) + self.video_widget.setStyleSheet("background: #000;") + lay.addWidget(self.video_widget, stretch=4) - # ── Timeline seek bar (video editor style) ── + # ── Timeline seek bar ── time_row = QtWidgets.QHBoxLayout() lay.addLayout(time_row) @@ -343,6 +252,7 @@ class VerifyWindow(QtWidgets.QMainWindow): self.seek.sliderPressed.connect(self._on_seek_start) self.seek.sliderMoved.connect(self._on_seek_move) self.seek.sliderReleased.connect(self._on_seek_end) + self._seeking = False # ── Combined waveform overlay ── self.gw_comb = pg.GraphicsLayoutWidget() @@ -353,6 +263,7 @@ class VerifyWindow(QtWidgets.QMainWindow): self.curve_a_comb = self.p_comb.plot(pen=pg.mkPen("#4ecdc4", width=1), name="A") self.curve_b_comb = self.p_comb.plot(pen=pg.mkPen("#ff6b6b", width=1, style=QtCore.Qt.PenStyle.DashLine), name="B") self.playhead_comb = self.p_comb.addLine(x=0, pen=pg.mkPen("#ffff00", width=2, style=QtCore.Qt.PenStyle.DashLine)) + self.p_comb.addLegend(offset=(60, 10)) lay.addWidget(self.gw_comb, stretch=1) # ── Two separate waveforms ── @@ -371,7 +282,6 @@ class VerifyWindow(QtWidgets.QMainWindow): self.curve_b_sep = self.p_b.plot(pen=pg.mkPen("#ff6b6b", width=1)) self.playhead_b = self.p_b.addLine(x=0, pen=pg.mkPen("#ffff00", width=2, style=QtCore.Qt.PenStyle.DashLine)) - # Link all x-axes for synchronized zoom/pan self.p_a.setXLink(self.p_comb) self.p_b.setXLink(self.p_comb) @@ -381,7 +291,6 @@ class VerifyWindow(QtWidgets.QMainWindow): ctrl = QtWidgets.QHBoxLayout() lay.addLayout(ctrl) - # Play mode ctrl.addWidget(QtWidgets.QLabel("Play:")) self.play_mode = QtWidgets.QComboBox() self.play_mode.addItems(["A+B mix", "A only", "B only"]) @@ -389,21 +298,19 @@ class VerifyWindow(QtWidgets.QMainWindow): self.play_mode.setFixedWidth(90) ctrl.addWidget(self.play_mode) - # Volume ctrl.addWidget(QtWidgets.QLabel("Vol:")) self.vol_slider = QtWidgets.QSlider(QtCore.Qt.Orientation.Horizontal) self.vol_slider.setRange(0, 100) - self.vol_slider.setValue(70) + self.vol_slider.setValue(self.default_volume) self.vol_slider.setMaximumWidth(100) ctrl.addWidget(self.vol_slider) - self.vol_label = QtWidgets.QLabel("70%") + self.vol_label = QtWidgets.QLabel(f"{self.default_volume}%") self.vol_label.setMinimumWidth(35) ctrl.addWidget(self.vol_label) self.vol_slider.valueChanged.connect(lambda v: self.vol_label.setText(f"{v}%")) ctrl.addSpacing(10) - # Toggle A/B self.chk_a = QtWidgets.QCheckBox("Show A") self.chk_a.setChecked(True) self.chk_a.toggled.connect(self._update_visibility) @@ -416,7 +323,6 @@ class VerifyWindow(QtWidgets.QMainWindow): ctrl.addSpacing(10) - # Reset zoom button btn_reset_zoom = QtWidgets.QPushButton("Reset zoom") btn_reset_zoom.setFixedWidth(90) btn_reset_zoom.clicked.connect(self._reset_zoom) @@ -424,16 +330,64 @@ class VerifyWindow(QtWidgets.QMainWindow): ctrl.addStretch() - # Info mode_str = "30s preview" if self.mode == "short" else "full length" self.info_label = QtWidgets.QLabel( f"[{mode_str}] Lag: {self.lag_sec:+.6f}s ({self.lag_samples} samp) | " f"A: {len(self.a_orig)/self.sr:.1f}s | B: {len(self.b_orig)/self.sr:.1f}s | " - f"Video: {self.v_duration:.1f}s" + f"Audio duration: {self.duration:.1f}s" ) self.info_label.setStyleSheet("color: #aaa; font-size: 11px;") ctrl.addWidget(self.info_label) + def _setup_video_player(self): + """Set up QMediaPlayer with QVideoWidget for smooth video playback.""" + self.media_player = QtMultimedia.QMediaPlayer() + self.video_output = QtMultimediaWidgets.QVideoWidget() + # Reparent video_output into our video_widget container + layout = self.video_widget.layout() + if layout is None: + layout = QtWidgets.QVBoxLayout(self.video_widget) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.video_output) + + self.media_player.setVideoOutput(self.video_output) + # Mute the video player's own audio — we play audio via sounddevice + self.audio_output = QtMultimedia.QAudioOutput() + self.audio_output.setMuted(True) + self.media_player.setAudioOutput(self.audio_output) + + # Load the video file + from PyQt6.QtCore import QUrl + self.media_player.setSource(QUrl.fromLocalFile(os.path.abspath(self.video_path))) + + # Timer to sync waveform playheads with video position + self.sync_timer = QtCore.QTimer() + self.sync_timer.setInterval(33) # ~30fps + self.sync_timer.timeout.connect(self._sync_from_video) + + self.media_player.playbackStateChanged.connect(self._on_playback_state) + + # Wait for media loaded to get actual duration + self.media_player.durationChanged.connect(self._on_duration_changed) + + def _on_duration_changed(self, dur_ms): + """QMediaPlayer reports actual video duration in ms.""" + vid_dur = dur_ms / 1000.0 + if vid_dur > 0: + self.info_label.setText( + self.info_label.text().replace( + f"Audio duration: {self.duration:.1f}s", + f"Video: {vid_dur:.1f}s" + ) + ) + + def _on_playback_state(self, state): + if state == QtMultimedia.QMediaPlayer.PlaybackState.StoppedState: + self.sync_timer.stop() + self.playing_a_b = False + self.btn_play.setText("Play") + self.audio_player.stop() + def _draw_waveforms(self): max_pts = 200_000 if self.mode == "full" else 80_000 @@ -459,40 +413,12 @@ class VerifyWindow(QtWidgets.QMainWindow): self.p_a.setYRange(-y_max, y_max) self.p_b.setYRange(-y_max, y_max) - # Set initial view to full duration - self.p_comb.setXRange(0, self.duration, padding=0) - - def _update_frame(self, t: float): - t = max(0, min(t, self.v_duration)) - # Avoid re-extracting same frame - if abs(t - self._last_frame_t) < 0.04: - return - self._last_frame_t = t - raw = extract_frame(self.video_path, t, self.v_width, self.v_height) - if raw: - img = raw_to_qimage(raw, self.v_width, self.v_height) - if img: - pixmap = QtGui.QPixmap.fromImage(img) - scaled = pixmap.scaled(self.video_label.size(), - QtCore.Qt.AspectRatioMode.KeepAspectRatio, - QtCore.Qt.TransformationMode.SmoothTransformation) - self.video_label.setPixmap(scaled) - - def _fmt_time(self, t: float) -> str: - h = int(t // 3600) - m = int((t % 3600) // 60) - s = t % 60 - return f"{h:02d}:{m:02d}:{s:06.3f}" - - def _current_pos(self) -> float: - if self.playing: - elapsed = time.time() - self.play_start_time - return min(self.play_offset + elapsed, self.duration) - return self.play_offset + for p in [self.p_comb, self.p_a, self.p_b]: + p.setXRange(0, self.duration, padding=0) + p.getViewBox().setLimits(xMin=0, xMax=self.duration) def _set_pos(self, t: float): t = max(0, min(t, self.duration)) - self.play_offset = t self.playhead_comb.setPos(t) self.playhead_a.setPos(t) self.playhead_b.setPos(t) @@ -503,22 +429,23 @@ class VerifyWindow(QtWidgets.QMainWindow): self.seek.blockSignals(False) self.time_label.setText(f"{self._fmt_time(t)} / {self._fmt_time(self.duration)}") - def _on_tick(self): - if not self.playing: - return - t = self._current_pos() - if t >= self.duration: - self.stop_play() - return + def _fmt_time(self, t: float) -> str: + h = int(t // 3600) + m = int((t % 3600) // 60) + s = t % 60 + return f"{h:02d}:{m:02d}:{s:06.3f}" + + def _sync_from_video(self): + """Timer callback: sync waveform playheads and seek bar to video position.""" + pos_ms = self.media_player.position() + t = pos_ms / 1000.0 + if t > self.duration: + t = self.duration self._set_pos(t) - # Update video frame (limit to ~8fps) - frame_interval = 1.0 / 8.0 - now = time.time() - if int(t / frame_interval) != int((t - 0.033) / frame_interval): - self._update_frame(t) def toggle_play(self): - if self.playing: + state = self.media_player.playbackState() + if state == QtMultimedia.QMediaPlayer.PlaybackState.PlayingState: self.stop_play() else: self.start_play() @@ -526,11 +453,16 @@ class VerifyWindow(QtWidgets.QMainWindow): def start_play(self): if self.duration <= 0: return - pos = self._current_pos() - if pos >= self.duration: - pos = 0.0 - start_samp = int(pos * self.sr) + # Start video playback + self.media_player.play() + + # Start audio mix via sounddevice + t = self.media_player.position() / 1000.0 + start_samp = int(t * self.sr) + if start_samp >= len(self.a_aligned): + start_samp = 0 + a_seg = self.a_aligned[start_samp:] b_seg = self.b_aligned[start_samp:] vol = self.vol_slider.value() / 100.0 @@ -544,23 +476,21 @@ class VerifyWindow(QtWidgets.QMainWindow): else: # B only mix = b_seg * vol - self.audio.play(mix, self.sr) - self.play_offset = pos - self.play_start_time = time.time() - self.playing = True + self.audio_player.play(mix, self.sr) + self.playing_a_b = True self.btn_play.setText("Pause") - self.timer.start() + self.sync_timer.start() def stop_play(self): - self.audio.stop() - self.play_offset = self._current_pos() - self.playing = False + self.media_player.pause() + self.audio_player.stop() + self.playing_a_b = False self.btn_play.setText("Play") - self.timer.stop() + self.sync_timer.stop() def _on_seek_start(self): self._seeking = True - was_playing = self.playing + was_playing = self.media_player.playbackState() == QtMultimedia.QMediaPlayer.PlaybackState.PlayingState if was_playing: self.stop_play() self._was_playing = was_playing @@ -569,14 +499,13 @@ class VerifyWindow(QtWidgets.QMainWindow): frac = value / 1_000_000.0 t = frac * self.duration self._set_pos(t) - self._update_frame(t) + self.media_player.setPosition(int(t * 1000)) def _on_seek_end(self): frac = self.seek.value() / 1_000_000.0 t = frac * self.duration - self.play_offset = t + self.media_player.setPosition(int(t * 1000)) self._set_pos(t) - self._update_frame(t) self._seeking = False if getattr(self, '_was_playing', False): self.start_play() @@ -591,9 +520,9 @@ class VerifyWindow(QtWidgets.QMainWindow): self.p_comb.setXRange(0, self.duration, padding=0) def closeEvent(self, event): - self.audio.stop() - self.timer.stop() - # Clean up temp files if any + self.media_player.stop() + self.audio_player.stop() + self.sync_timer.stop() if self.temp_dir and os.path.isdir(self.temp_dir): try: shutil.rmtree(self.temp_dir) @@ -620,14 +549,13 @@ def main(): audio_cfg = cfg.get("audio", {}) sr = int(audio_cfg.get("sample_rate", 48000)) + volume = int(audio_cfg.get("volume", 70)) out_dir = args.out or sync_cfg.get("out_dir", "output") lag_sec = args.lag if args.mode == "full": - # Full mode: use synced MKV, extract audio streams from it synced = args.synced if not synced: - # Auto-derive from config video_a = ext.get("file_a", "") stem = os.path.splitext(os.path.basename(video_a))[0] synced = os.path.join(out_dir, f"{stem}_synced.mkv") @@ -645,7 +573,6 @@ def main(): print("ERROR: synced MKV needs at least 2 audio streams (A + B)", file=sys.stderr) sys.exit(1) - # Extract both audio streams to temp WAVs temp_dir = tempfile.mkdtemp(prefix="verify_sync_") wav_a = os.path.join(temp_dir, "audio_a.wav") wav_b = os.path.join(temp_dir, "audio_b.wav") @@ -663,7 +590,6 @@ def main(): video_path = synced else: - # Short mode: use original video + extracted WAVs video_a = args.a or ext.get("file_a", "") video_b = args.b or ext.get("file_b", "") @@ -699,7 +625,8 @@ def main(): apply_dark_theme(app) w = VerifyWindow(video_path, wav_a, wav_b, sr=sr, lag_sec=lag_sec, - mode=args.mode, out_dir=out_dir, temp_dir=temp_dir) + mode=args.mode, out_dir=out_dir, temp_dir=temp_dir, + volume=volume) w.show() sys.exit(app.exec())