fix: mpv in verify_sync
This commit is contained in:
1 parent
963e95c37a
commit
f883c78b4b
1 file changed
+175
-91
+175
-91
@@ -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,35 +582,33 @@ 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._was_playing:
|
||||
self.stop_play()
|
||||
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)
|
||||
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")
|
||||
|
||||
Reference in new issue
Block a user