#!/usr/bin/env python3 """ Verify sync: mpv video player (separate window) + 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: - Smooth video playback via mpv (separate window, IPC-controlled) - 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 (scroll) with linked x-axes - Timeline seek bar synced to video position - Toggle A/B on/off, volume """ from __future__ import annotations import os, sys, math, argparse, subprocess, json, shutil, threading, tempfile, warnings, signal, locale, socket, time, socketserver os.environ["LC_NUMERIC"] = "C" locale.setlocale(locale.LC_NUMERIC, "C") import numpy as np warnings.filterwarnings("ignore", category=RuntimeWarning, module="pyqtgraph") try: import tomllib except ModuleNotFoundError: import tomli as tomllib try: import pyqtgraph as pg from PyQt6 import QtWidgets, QtCore, QtGui except ImportError as e: print("ERROR: pyqtgraph/PyQt6 not installed — run make install", file=sys.stderr) sys.exit(1) try: import sounddevice as sd HAS_SD = True except ImportError: HAS_SD = False from sync_audio import load_wav from waveform_cache import cached_load_wav # ─── Helpers ─── def load_config(path: str) -> dict: if not os.path.exists(path): return {} with open(path, "rb") as f: return tomllib.load(f) def probe_audio_streams(path: str) -> list[dict]: try: r = subprocess.run( ["ffprobe", "-v", "error", "-select_streams", "a", "-show_entries", "stream=index,codec_name,channels,sample_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_from_mkv(mkv_path: str, stream_index: int, out_wav: str, sr: int = 48000) -> bool: try: r = subprocess.run( ["ffmpeg", "-y", "-i", mkv_path, "-map", f"0:a:{stream_index}", "-acodec", "pcm_f32le", "-ar", str(sr), "-ac", "1", "-v", "error", out_wav], capture_output=True, timeout=300 ) return r.returncode == 0 except Exception: return False def apply_lag(a: np.ndarray, b: np.ndarray, lag_samples: int) -> tuple[np.ndarray, np.ndarray]: if lag_samples >= 0: a_out = a[lag_samples:] b_out = np.concatenate([np.zeros(lag_samples, dtype=np.float32), b]) else: pad = -lag_samples a_out = np.concatenate([np.zeros(pad, dtype=np.float32), a]) b_out = b[pad:] ml = min(len(a_out), len(b_out)) return a_out[:ml], b_out[:ml] def apply_dark_theme(app: QtWidgets.QApplication): app.setStyle("Fusion") dark = QtGui.QPalette() dark.setColor(QtGui.QPalette.ColorRole.Window, QtGui.QColor(45, 45, 45)) dark.setColor(QtGui.QPalette.ColorRole.WindowText, QtGui.QColor(220, 220, 220)) dark.setColor(QtGui.QPalette.ColorRole.Base, QtGui.QColor(30, 30, 30)) dark.setColor(QtGui.QPalette.ColorRole.AlternateBase, QtGui.QColor(45, 45, 45)) dark.setColor(QtGui.QPalette.ColorRole.ToolTipBase, QtGui.QColor(45, 45, 45)) dark.setColor(QtGui.QPalette.ColorRole.ToolTipText, QtGui.QColor(220, 220, 220)) dark.setColor(QtGui.QPalette.ColorRole.Text, QtGui.QColor(220, 220, 220)) dark.setColor(QtGui.QPalette.ColorRole.Button, QtGui.QColor(45, 45, 45)) dark.setColor(QtGui.QPalette.ColorRole.ButtonText, QtGui.QColor(220, 220, 220)) dark.setColor(QtGui.QPalette.ColorRole.BrightText, QtGui.QColor(255, 50, 50)) dark.setColor(QtGui.QPalette.ColorRole.Link, QtGui.QColor(100, 180, 255)) dark.setColor(QtGui.QPalette.ColorRole.Highlight, QtGui.QColor(60, 120, 200)) dark.setColor(QtGui.QPalette.ColorRole.HighlightedText, QtGui.QColor(255, 255, 255)) dark.setColor(QtGui.QPalette.ColorGroup.Disabled, QtGui.QPalette.ColorRole.Text, QtGui.QColor(120, 120, 120)) dark.setColor(QtGui.QPalette.ColorGroup.Disabled, QtGui.QPalette.ColorRole.ButtonText, QtGui.QColor(120, 120, 120)) app.setPalette(dark) pg.setConfigOptions(antialias=True, background="#1e1e1e", foreground="#ddd") # ─── Audio playback ─── class AudioPlayer: def __init__(self): self.playing = False self._stream = None self._lock = threading.Lock() def play(self, data: np.ndarray, sr: int): if not HAS_SD: return with self._lock: sd.stop() self.playing = True try: self._stream = sd.play(data, samplerate=sr, blocking=False) except Exception as e: print(f"audio play error: {e}", file=sys.stderr) self.playing = False def stop(self): if HAS_SD: with self._lock: sd.stop() self._stream = None self.playing = False # ─── mpv IPC controller ─── class MpvController: """Control mpv as a standalone process via JSON IPC socket.""" def __init__(self, video_path: str): self._sock_path = f"/tmp/mpv-verify-{os.getpid()}.sock" self._cmd_id = 0 self._lock = threading.Lock() self._proc = None self._sock = None # Remove stale socket if os.path.exists(self._sock_path): os.unlink(self._sock_path) # Launch mpv with IPC server self._proc = subprocess.Popen([ "mpv", "--no-terminal", "--keep-open=yes", "--pause", "--no-audio", f"--input-ipc-server={self._sock_path}", video_path, ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) # Wait for socket to appear for _ in range(50): if os.path.exists(self._sock_path): break time.sleep(0.05) self._connect() def _connect(self): self._sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) self._sock.connect(self._sock_path) self._sock.settimeout(0.5) # Read any welcome message try: self._sock.recv(4096) except socket.timeout: pass def _send(self, command: list, timeout: float = 2.0) -> dict | None: with self._lock: self._cmd_id += 1 msg = json.dumps({"command": command, "request_id": self._cmd_id}) + "\n" self._sock.sendall(msg.encode()) deadline = time.monotonic() + timeout while time.monotonic() < deadline: try: data = self._sock.recv(4096) if not data: break for line in data.decode().strip().split("\n"): try: resp = json.loads(line) if resp.get("request_id") == self._cmd_id: return resp except json.JSONDecodeError: continue except socket.timeout: continue return None def get_property(self, prop: str): resp = self._send(["get_property", prop]) if resp and resp.get("error") == "success": return resp.get("data") return None def set_property(self, prop: str, value): self._send(["set_property", prop, value]) @property def time_pos(self) -> float: v = self.get_property("time-pos") return float(v) if v is not None else 0.0 @property def duration(self) -> float: v = self.get_property("duration") return float(v) if v is not None else 0.0 @property def paused(self) -> bool: v = self.get_property("pause") return bool(v) if v is not None else True def play(self): self.set_property("pause", False) def pause(self): self.set_property("pause", True) def seek(self, seconds: float): self._send(["seek", seconds, "absolute"]) def stop(self): try: self._send(["stop"], timeout=0.5) except Exception: pass def terminate(self): try: self._send(["quit"], timeout=0.5) except Exception: pass if self._proc: try: self._proc.wait(timeout=2) except subprocess.TimeoutExpired: self._proc.kill() if self._sock: try: self._sock.close() except Exception: pass if os.path.exists(self._sock_path): try: os.unlink(self._sock_path) except Exception: pass # ─── 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, volume: int = 70, cache_key_a: str | None = None, cache_key_b: str | None = None): super().__init__() self.video_path = video_path self.wav_a_path = wav_a_path self.wav_b_path = wav_b_path self.sr = sr self.lag_sec = lag_sec self.lag_samples = int(round(lag_sec * sr)) self.mode = mode self.out_dir = out_dir self.temp_dir = temp_dir self.audio_player = AudioPlayer() self.default_volume = volume self.duration = 0.0 self.playing_a_b = False self.mpv: MpvController | None = None title = "verify_sync" if mode == "short": title += " — 30s preview" else: title += " — full length" title += f" — {os.path.basename(video_path)}" self.setWindowTitle(title) self.resize(1400, 700) # Load audio print(f"Loading audio A: {wav_a_path}") self.a_orig, _ = cached_load_wav(wav_a_path, target_sr=sr, _loader=load_wav, _cache_key=cache_key_a) print(f"Loading audio B: {wav_b_path}") self.b_orig, _ = cached_load_wav(wav_b_path, target_sr=sr, _loader=load_wav, _cache_key=cache_key_b) # Apply lag alignment self.a_aligned, self.b_aligned = apply_lag(self.a_orig, self.b_orig, self.lag_samples) self.duration = len(self.a_aligned) / sr self._setup_ui() self._draw_waveforms() # Start mpv in its own window QtCore.QTimer.singleShot(0, self._start_mpv) # Sync timer: polls mpv position and updates waveforms self.sync_timer = QtCore.QTimer() self.sync_timer.setInterval(33) # ~30fps self.sync_timer.timeout.connect(self._sync_from_mpv) self.sync_timer.start() def _setup_ui(self): central = QtWidgets.QWidget() self.setCentralWidget(central) lay = QtWidgets.QVBoxLayout(central) lay.setSpacing(2) # ── Timeline seek bar ── time_row = QtWidgets.QHBoxLayout() lay.addLayout(time_row) self.btn_play = QtWidgets.QPushButton("Play") self.btn_play.setFixedWidth(60) time_row.addWidget(self.btn_play) self.btn_stop = QtWidgets.QPushButton("Stop") self.btn_stop.setFixedWidth(60) time_row.addWidget(self.btn_stop) self.seek = QtWidgets.QSlider(QtCore.Qt.Orientation.Horizontal) self.seek.setRange(0, 1_000_000) self.seek.setValue(0) time_row.addWidget(self.seek, stretch=1) self.time_label = QtWidgets.QLabel("00:00:00.000 / 00:00:00.000") self.time_label.setMinimumWidth(220) self.time_label.setStyleSheet("font-family: monospace; font-size: 12px;") time_row.addWidget(self.time_label) self.btn_play.clicked.connect(self.toggle_play) self.btn_stop.clicked.connect(self.stop_play) 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() self.gw_comb.setMinimumHeight(100) self.p_comb = self.gw_comb.addPlot(title="A (teal) + B (red) overlay") self.p_comb.showGrid(x=True, y=True, alpha=0.3) self.p_comb.setLabel("bottom", "Time", "s") 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 ── self.gw_dual = pg.GraphicsLayoutWidget() self.gw_dual.setMinimumHeight(200) self.p_a = self.gw_dual.addPlot(row=0, col=0, title="Audio A") self.p_a.showGrid(x=True, y=True, alpha=0.3) self.p_a.setLabel("bottom", "Time", "s") self.curve_a_sep = self.p_a.plot(pen=pg.mkPen("#4ecdc4", width=1)) self.playhead_a = self.p_a.addLine(x=0, pen=pg.mkPen("#ffff00", width=2, style=QtCore.Qt.PenStyle.DashLine)) self.p_b = self.gw_dual.addPlot(row=1, col=0, title="Audio B (+delay)") self.p_b.showGrid(x=True, y=True, alpha=0.3) self.p_b.setLabel("bottom", "Time", "s") 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)) self.p_a.setXLink(self.p_comb) self.p_b.setXLink(self.p_comb) for p in [self.p_comb, self.p_a, self.p_b]: p.enableAutoRange(axis='x', enable=False) p.enableAutoRange(axis='y', enable=False) lay.addWidget(self.gw_dual, stretch=2) # ── Controls row ── ctrl = QtWidgets.QHBoxLayout() lay.addLayout(ctrl) ctrl.addWidget(QtWidgets.QLabel("Play:")) self.play_mode = QtWidgets.QComboBox() self.play_mode.addItems(["A+B mix", "A only", "B only"]) self.play_mode.setCurrentIndex(0) self.play_mode.setFixedWidth(90) ctrl.addWidget(self.play_mode) ctrl.addSpacing(10) ctrl.addWidget(QtWidgets.QLabel("Vol A:")) self.vol_a_slider = QtWidgets.QSlider(QtCore.Qt.Orientation.Horizontal) self.vol_a_slider.setRange(0, 200) self.vol_a_slider.setValue(self.default_volume) self.vol_a_slider.setMaximumWidth(100) ctrl.addWidget(self.vol_a_slider) self.vol_a_label = QtWidgets.QLabel(f"{self.default_volume}%") self.vol_a_label.setMinimumWidth(35) ctrl.addWidget(self.vol_a_label) self.vol_a_slider.valueChanged.connect(lambda v: self.vol_a_label.setText(f"{v}%")) ctrl.addWidget(QtWidgets.QLabel("Vol B:")) self.vol_b_slider = QtWidgets.QSlider(QtCore.Qt.Orientation.Horizontal) self.vol_b_slider.setRange(0, 200) self.vol_b_slider.setValue(self.default_volume) self.vol_b_slider.setMaximumWidth(100) ctrl.addWidget(self.vol_b_slider) self.vol_b_label = QtWidgets.QLabel(f"{self.default_volume}%") self.vol_b_label.setMinimumWidth(35) ctrl.addWidget(self.vol_b_label) self.vol_b_slider.valueChanged.connect(lambda v: self.vol_b_label.setText(f"{v}%")) ctrl.addSpacing(10) self.chk_a = QtWidgets.QCheckBox("Show A") self.chk_a.setChecked(True) self.chk_a.toggled.connect(self._update_visibility) ctrl.addWidget(self.chk_a) self.chk_b = QtWidgets.QCheckBox("Show B") self.chk_b.setChecked(True) self.chk_b.toggled.connect(self._update_visibility) ctrl.addWidget(self.chk_b) ctrl.addSpacing(10) btn_reset_zoom = QtWidgets.QPushButton("Reset zoom") btn_reset_zoom.setFixedWidth(90) btn_reset_zoom.clicked.connect(self._reset_zoom) ctrl.addWidget(btn_reset_zoom) ctrl.addStretch() 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"Audio duration: {self.duration:.1f}s" ) self.info_label.setStyleSheet("color: #aaa; font-size: 11px;") ctrl.addWidget(self.info_label) def _start_mpv(self): """Launch mpv in its own window, controlled via IPC socket.""" if not self.video_path or not os.path.exists(self.video_path): print(f"mpv skipped: no video file available", file=sys.stderr) self.info_label.setText(self.info_label.text() + " | mpv: unavailable") return print(f"Launching mpv: {self.video_path}") try: self.mpv = MpvController(self.video_path) vid_dur = self.mpv.duration 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" ) ) except Exception as e: print(f"ERROR: failed to start mpv: {e}", file=sys.stderr) self.info_label.setText(self.info_label.text() + " | mpv: FAILED") def _draw_waveforms(self): max_pts = 200_000 if self.mode == "full" else 80_000 def decimate(arr): if len(arr) <= max_pts: return arr, np.arange(len(arr), dtype=np.float64) / self.sr stride = int(math.ceil(len(arr) / max_pts)) dec = arr[::stride] t = np.arange(0, len(arr), stride).astype(np.float64) / self.sr return dec, t a_dec, t_a = decimate(self.a_aligned) b_dec, t_b = decimate(self.b_aligned) self.curve_a_comb.setData(t_a, a_dec) self.curve_b_comb.setData(t_b, b_dec) self.curve_a_sep.setData(t_a, a_dec) self.curve_b_sep.setData(t_b, b_dec) y_max = max(np.max(np.abs(a_dec)), np.max(np.abs(b_dec))) * 1.1 if y_max > 0: self.p_comb.setYRange(-y_max, y_max) self.p_a.setYRange(-y_max, y_max) self.p_b.setYRange(-y_max, y_max) 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.playhead_comb.setPos(t) self.playhead_a.setPos(t) self.playhead_b.setPos(t) if not self._seeking: frac = t / self.duration if self.duration > 0 else 0 self.seek.blockSignals(True) self.seek.setValue(int(frac * 1_000_000)) self.seek.blockSignals(False) self.time_label.setText(f"{self._fmt_time(t)} / {self._fmt_time(self.duration)}") 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_mpv(self): """Poll mpv position and play/pause state, update waveforms and audio.""" if not self.mpv: return try: t = self.mpv.time_pos paused = self.mpv.paused except Exception: t = 0 paused = True if t > self.duration: t = self.duration self._set_pos(t) if not paused and not self.playing_a_b: self.start_play() elif paused and self.playing_a_b: self.stop_play() def toggle_play(self): if not self.mpv: return try: if not self.mpv.paused: self.stop_play() else: self.start_play() except Exception: self.start_play() def start_play(self): if self.duration <= 0 or not self.mpv: return # Start video playback in mpv self.mpv.play() # Start audio mix via sounddevice t = self.mpv.time_pos 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_a = self.vol_a_slider.value() / 100.0 vol_b = self.vol_b_slider.value() / 100.0 mode = self.play_mode.currentIndex() if mode == 0: # A+B mix ml = min(len(a_seg), len(b_seg)) mix = (a_seg[:ml] * vol_a + b_seg[:ml] * vol_b) / 2.0 elif mode == 1: # A only mix = a_seg * vol_a else: # B only mix = b_seg * vol_b self.audio_player.play(mix, self.sr) self.playing_a_b = True self.btn_play.setText("Pause") def stop_play(self): if self.mpv: self.mpv.pause() self.audio_player.stop() self.playing_a_b = False self.btn_play.setText("Play") def _on_seek_start(self): self._seeking = True if self.mpv: self._was_playing = not self.mpv.paused if self._was_playing: self.stop_play() def _on_seek_move(self, value): frac = value / 1_000_000.0 t = frac * self.duration self._set_pos(t) if self.mpv: self.mpv.seek(t) def _on_seek_end(self): frac = self.seek.value() / 1_000_000.0 t = frac * self.duration if self.mpv: self.mpv.seek(t) self._set_pos(t) self._seeking = False if getattr(self, '_was_playing', False): self.start_play() def _update_visibility(self): self.curve_a_comb.setVisible(self.chk_a.isChecked()) self.curve_b_comb.setVisible(self.chk_b.isChecked()) self.curve_a_sep.setVisible(self.chk_a.isChecked()) self.curve_b_sep.setVisible(self.chk_b.isChecked()) def _reset_zoom(self): self.p_comb.enableAutoRange(axis='x', enable=False) self.p_comb.setXRange(0, self.duration, padding=0) def closeEvent(self, event): if self.mpv: self.mpv.terminate() 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) except Exception: pass super().closeEvent(event) def main(): ap = argparse.ArgumentParser(description="Verify sync: video + dual waveform playback") ap.add_argument("--config", default="config.toml") ap.add_argument("--a", default=None, help="Video file A") ap.add_argument("--b", default=None, help="Video file B") ap.add_argument("--wav-a", default=None, help="WAV file for audio A") ap.add_argument("--wav-b", default=None, help="WAV file for audio B") ap.add_argument("--synced", default=None, help="Synced MKV output (full mode)") ap.add_argument("--lag", type=float, default=0.0, help="Lag in seconds") ap.add_argument("--mode", choices=["short", "full"], default="short", help="short = extracted 30s parts, full = synced MKV output") ap.add_argument("--out", default=None, help="Output dir") args = ap.parse_args() cfg = load_config(args.config) sync_cfg = cfg.get("sync", {}) 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 # Load stream selection from .stream_state (created by extract.py) _state_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".stream_state") _state = {} if os.path.exists(_state_path): try: import json with open(_state_path, "r") as _sf: _state = json.load(_sf) except Exception: pass ext = cfg.get("extract", {}) if args.mode == "full": synced = args.synced if not synced: video_a = args.a or ext.get("file_a", "") stem = os.path.splitext(os.path.basename(video_a))[0] synced = os.path.join(out_dir, f"{stem}_synced.mkv") if not os.path.exists(synced): print(f"ERROR: synced MKV not found: {synced}", file=sys.stderr) sys.exit(1) print(f"Synced MKV: {synced}") streams = probe_audio_streams(synced) print(f"Audio streams in synced MKV: {len(streams)}") for i, s in enumerate(streams): print(f" [{i}] {s['language']} {s['codec'].upper()} {s['channels']}ch") if len(streams) < 2: print("ERROR: synced MKV needs at least 2 audio streams (A + B)", file=sys.stderr) sys.exit(1) # Determine which streams to extract from synced MKV # A's streams are first, B's delayed streams follow video_a = args.a or ext.get("file_a", "") a_streams_in = probe_audio_streams(video_a) if os.path.exists(video_a) else [] n_a = len(a_streams_in) sel_a = _state.get("audio_stream_a", 0) or 0 sel_b = _state.get("audio_stream_b", 0) or 0 sel_a = int(sel_a) sel_b = int(sel_b) # Output indices: A's selected stream stays at sel_a, B's becomes n_a + sel_b out_idx_a = sel_a out_idx_b = n_a + sel_b print(f"A has {n_a} streams, selected [{sel_a}] -> output [{out_idx_a}]") print(f"B selected [{sel_b}] -> output [{out_idx_b}]") if out_idx_a >= len(streams) or out_idx_b >= len(streams): print(f"ERROR: computed stream indices [{out_idx_a}, {out_idx_b}] out of range " f"(file has {len(streams)} streams)", file=sys.stderr) sys.exit(1) 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") # Stable cache keys based on source MKV + stream index (not temp file path) import hashlib _mkv_mtime = os.path.getmtime(synced) cache_key_a = hashlib.sha256(f"{synced}|{out_idx_a}|{sr}|{_mkv_mtime:.6f}".encode()).hexdigest()[:16] cache_key_b = hashlib.sha256(f"{synced}|{out_idx_b}|{sr}|{_mkv_mtime:.6f}".encode()).hexdigest()[:16] print(f"Extracting audio stream {out_idx_a} (A) to {wav_a}...") if not extract_audio_from_mkv(synced, out_idx_a, wav_a, sr): print(f"ERROR: failed to extract audio stream {out_idx_a}", file=sys.stderr) sys.exit(1) print(f"Extracting audio stream {out_idx_b} (B) to {wav_b}...") if not extract_audio_from_mkv(synced, out_idx_b, wav_b, sr): print(f"ERROR: failed to extract audio stream {out_idx_b}", file=sys.stderr) sys.exit(1) video_path = synced else: video_a = args.a or ext.get("file_a", "") video_b = args.b or ext.get("file_b", "") out_dir_extract = ext.get("out_dir", "extracted") if args.wav_a: wav_a = args.wav_a else: stem_a = os.path.splitext(os.path.basename(video_a))[0] wav_a = os.path.join(out_dir_extract, f"{stem_a}_audio.wav") if args.wav_b: wav_b = args.wav_b else: stem_b = os.path.splitext(os.path.basename(video_b))[0] wav_b = os.path.join(out_dir_extract, f"{stem_b}_audio.wav") if not wav_a or not os.path.exists(wav_a): print(f"ERROR: wav A not found: {wav_a}", file=sys.stderr); sys.exit(1) if not wav_b or not os.path.exists(wav_b): print(f"ERROR: wav B not found: {wav_b}", file=sys.stderr); sys.exit(1) video_path = video_a if args.mode == "short": stem = os.path.splitext(os.path.basename(video_a))[0] extracted_video = os.path.join(out_dir_extract, f"{stem}_video.mkv") if os.path.exists(extracted_video): video_path = extracted_video print(f"Using extracted video: {video_path}") elif video_a and os.path.exists(video_a): pass # use original else: print(f"WARNING: no video found (extracted or original) for A, mpv will be unavailable", file=sys.stderr) else: if not video_a or not os.path.exists(video_a): print(f"ERROR: video A not found: {video_a}", file=sys.stderr); sys.exit(1) temp_dir = None cache_key_a = None cache_key_b = None print(f"Mode: {args.mode}") print(f"Video: {video_path}") print(f"WAV A: {wav_a}") print(f"WAV B: {wav_b}") print(f"Lag: {lag_sec:+.6f}s") app = QtWidgets.QApplication(sys.argv) signal.signal(signal.SIGINT, signal.SIG_DFL) 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, volume=volume, cache_key_a=cache_key_a, cache_key_b=cache_key_b) w.show() sys.exit(app.exec()) if __name__ == "__main__": main()