fix: mpv in verify_sync

This commit is contained in:
Honney OC committed 2026-09-14 00:38:31 +00:00
1 parent 963e95c37a
commit f883c78b4b
1 file changed
+173 -89
+173 -89
View File
@@ -1,28 +1,23 @@
#!/usr/bin/env python3
"""
Verify sync: mpv video player + dual waveform display.
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 (handles all codecs natively)
- 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
Usage:
make verify LAG=0.042
python verify_sync.py --config config.toml --lag 0.042 --mode short
python verify_sync.py --synced output/movie_synced.mkv --lag 0.042 --mode full
"""
from __future__ import annotations
import os, sys, math, argparse, subprocess, json, shutil, threading, tempfile, warnings, signal, locale
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")
@@ -39,19 +34,10 @@ except ModuleNotFoundError:
try:
import pyqtgraph as pg
from PyQt6 import QtWidgets, QtCore, QtGui
HAS_PG = True
except ImportError as e:
print("ERROR: pyqtgraph/PyQt6 not installed — run make install", file=sys.stderr)
sys.exit(1)
try:
import mpv
HAS_MPV = True
except ImportError:
HAS_MPV = False
print("ERROR: python-mpv not installed — pip install python-mpv", file=sys.stderr)
sys.exit(1)
try:
import sounddevice as sd
HAS_SD = True
@@ -175,6 +161,134 @@ class AudioPlayer:
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):
@@ -196,6 +310,7 @@ class VerifyWindow(QtWidgets.QMainWindow):
self.default_volume = volume
self.duration = 0.0
self.playing_a_b = False
self.mpv: MpvController | None = None
title = "verify_sync"
if mode == "short":
@@ -204,7 +319,7 @@ class VerifyWindow(QtWidgets.QMainWindow):
title += " — full length"
title += f" — {os.path.basename(video_path)}"
self.setWindowTitle(title)
self.resize(1400, 950)
self.resize(1400, 700)
# Load audio
print(f"Loading audio A: {wav_a_path}")
@@ -218,8 +333,15 @@ class VerifyWindow(QtWidgets.QMainWindow):
self._setup_ui()
self._draw_waveforms()
# Defer mpv setup until window is shown and winId() is valid
QtCore.QTimer.singleShot(0, self._setup_mpv)
# 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()
@@ -227,12 +349,6 @@ class VerifyWindow(QtWidgets.QMainWindow):
lay = QtWidgets.QVBoxLayout(central)
lay.setSpacing(2)
# ── Video display (QWidget container for mpv) ──
self.video_container = QtWidgets.QWidget()
self.video_container.setMinimumHeight(300)
self.video_container.setStyleSheet("background: #000;")
lay.addWidget(self.video_container, stretch=4)
# ── Timeline seek bar ──
time_row = QtWidgets.QHBoxLayout()
lay.addLayout(time_row)
@@ -350,48 +466,22 @@ class VerifyWindow(QtWidgets.QMainWindow):
self.info_label.setStyleSheet("color: #aaa; font-size: 11px;")
ctrl.addWidget(self.info_label)
def _setup_mpv(self):
"""Set up mpv player embedded in the video container widget."""
self.mpv_player = mpv.MPV(
wid=str(int(self.video_container.winId())),
input_default_bindings=False,
input_vo_keyboard=False,
cursor_autohide=False,
keep_open=True,
osc=False,
osd_bar=False,
)
# Mute mpv audio — we play audio via sounddevice
self.mpv_player.audio = "no"
self.mpv_player.play(os.path.abspath(self.video_path))
# Timer to sync waveform playheads with mpv position
self.sync_timer = QtCore.QTimer()
self.sync_timer.setInterval(33) # ~30fps
self.sync_timer.timeout.connect(self._sync_from_video)
# Wait for mpv to load duration
self._duration_timer = QtCore.QTimer()
self._duration_timer.setInterval(100)
self._duration_timer.timeout.connect(self._wait_for_duration)
self._duration_timer.start()
def _wait_for_duration(self):
"""Poll mpv until duration is available."""
def _start_mpv(self):
"""Launch mpv in its own window, controlled via IPC socket."""
print(f"Launching mpv: {self.video_path}")
try:
dur = self.mpv_player.duration
if dur and dur > 0:
self._duration_timer.stop()
vid_dur = float(dur)
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:
pass
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
@@ -440,10 +530,12 @@ class VerifyWindow(QtWidgets.QMainWindow):
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 mpv position."""
def _sync_from_mpv(self):
"""Poll mpv position and update waveforms."""
if not self.mpv:
return
try:
t = float(self.mpv_player.time_pos or 0)
t = self.mpv.time_pos
except Exception:
t = 0
if t > self.duration:
@@ -451,9 +543,10 @@ class VerifyWindow(QtWidgets.QMainWindow):
self._set_pos(t)
def toggle_play(self):
if not self.mpv:
return
try:
paused = self.mpv_player.pause
if paused is False:
if not self.mpv.paused:
self.stop_play()
else:
self.start_play()
@@ -461,17 +554,14 @@ class VerifyWindow(QtWidgets.QMainWindow):
self.start_play()
def start_play(self):
if self.duration <= 0:
if self.duration <= 0 or not self.mpv:
return
# Start video playback
self.mpv_player.pause = False
# Start video playback in mpv
self.mpv.play()
# Start audio mix via sounddevice
try:
t = float(self.mpv_player.time_pos or 0)
except Exception:
t = 0
t = self.mpv.time_pos
start_samp = int(t * self.sr)
if start_samp >= len(self.a_aligned):
start_samp = 0
@@ -492,22 +582,18 @@ class VerifyWindow(QtWidgets.QMainWindow):
self.audio_player.play(mix, self.sr)
self.playing_a_b = True
self.btn_play.setText("Pause")
self.sync_timer.start()
def stop_play(self):
self.mpv_player.pause = True
if self.mpv:
self.mpv.pause()
self.audio_player.stop()
self.playing_a_b = False
self.btn_play.setText("Play")
self.sync_timer.stop()
def _on_seek_start(self):
self._seeking = True
try:
paused = self.mpv_player.pause
self._was_playing = (paused is False)
except Exception:
self._was_playing = False
if self.mpv:
self._was_playing = not self.mpv.paused
if self._was_playing:
self.stop_play()
@@ -515,12 +601,14 @@ class VerifyWindow(QtWidgets.QMainWindow):
frac = value / 1_000_000.0
t = frac * self.duration
self._set_pos(t)
self.mpv_player.seek(t, reference='absolute')
if self.mpv:
self.mpv.seek(t)
def _on_seek_end(self):
frac = self.seek.value() / 1_000_000.0
t = frac * self.duration
self.mpv_player.seek(t, reference='absolute')
if self.mpv:
self.mpv.seek(t)
self._set_pos(t)
self._seeking = False
if getattr(self, '_was_playing', False):
@@ -537,11 +625,8 @@ class VerifyWindow(QtWidgets.QMainWindow):
self.p_comb.setXRange(0, self.duration, padding=0)
def closeEvent(self, event):
try:
self.mpv_player.stop()
self.mpv_player.terminate()
except Exception:
pass
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):
@@ -634,7 +719,6 @@ def main():
print(f"ERROR: wav B not found: {wav_b}", file=sys.stderr); sys.exit(1)
video_path = video_a
# In short mode, use the extracted FFV1 video instead of the original HEVC source
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")