fix: mpv in verify_sync

This commit is contained in:
Honney OC committed 2026-09-14 00:38:31 +00:00
1 parent 003fb84e35
commit 0af0d06809
1 file changed
+173 -89
+173 -89
View File
@@ -1,28 +1,23 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Verify sync: mpv video player + dual waveform display. Verify sync: mpv video player (separate window) + dual waveform display.
Modes: Modes:
--mode short : extracted 30s parts (video_a + wav_a + wav_b) --mode short : extracted 30s parts (video_a + wav_a + wav_b)
--mode full : synced output MKV (extracts audio streams from it) --mode full : synced output MKV (extracts audio streams from it)
Features: 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 - Audio playback: A only, B only, or A+B mix via sounddevice
- Combined waveform overlay (A and B in different colors) - Combined waveform overlay (A and B in different colors)
- Two separate waveform graphs with playhead - Two separate waveform graphs with playhead
- Zoomable waveforms (scroll) with linked x-axes - Zoomable waveforms (scroll) with linked x-axes
- Timeline seek bar synced to video position - Timeline seek bar synced to video position
- Toggle A/B on/off, volume - 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 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" os.environ["LC_NUMERIC"] = "C"
locale.setlocale(locale.LC_NUMERIC, "C") locale.setlocale(locale.LC_NUMERIC, "C")
@@ -39,19 +34,10 @@ except ModuleNotFoundError:
try: try:
import pyqtgraph as pg import pyqtgraph as pg
from PyQt6 import QtWidgets, QtCore, QtGui from PyQt6 import QtWidgets, QtCore, QtGui
HAS_PG = True
except ImportError as e: except ImportError as e:
print("ERROR: pyqtgraph/PyQt6 not installed — run make install", file=sys.stderr) print("ERROR: pyqtgraph/PyQt6 not installed — run make install", file=sys.stderr)
sys.exit(1) 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: try:
import sounddevice as sd import sounddevice as sd
HAS_SD = True HAS_SD = True
@@ -175,6 +161,134 @@ class AudioPlayer:
self.playing = False 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 ─── # ─── Main Window ───
class VerifyWindow(QtWidgets.QMainWindow): class VerifyWindow(QtWidgets.QMainWindow):
@@ -196,6 +310,7 @@ class VerifyWindow(QtWidgets.QMainWindow):
self.default_volume = volume self.default_volume = volume
self.duration = 0.0 self.duration = 0.0
self.playing_a_b = False self.playing_a_b = False
self.mpv: MpvController | None = None
title = "verify_sync" title = "verify_sync"
if mode == "short": if mode == "short":
@@ -204,7 +319,7 @@ class VerifyWindow(QtWidgets.QMainWindow):
title += " — full length" title += " — full length"
title += f" — {os.path.basename(video_path)}" title += f" — {os.path.basename(video_path)}"
self.setWindowTitle(title) self.setWindowTitle(title)
self.resize(1400, 950) self.resize(1400, 700)
# Load audio # Load audio
print(f"Loading audio A: {wav_a_path}") print(f"Loading audio A: {wav_a_path}")
@@ -218,8 +333,15 @@ class VerifyWindow(QtWidgets.QMainWindow):
self._setup_ui() self._setup_ui()
self._draw_waveforms() 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): def _setup_ui(self):
central = QtWidgets.QWidget() central = QtWidgets.QWidget()
@@ -227,12 +349,6 @@ class VerifyWindow(QtWidgets.QMainWindow):
lay = QtWidgets.QVBoxLayout(central) lay = QtWidgets.QVBoxLayout(central)
lay.setSpacing(2) 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 ── # ── Timeline seek bar ──
time_row = QtWidgets.QHBoxLayout() time_row = QtWidgets.QHBoxLayout()
lay.addLayout(time_row) lay.addLayout(time_row)
@@ -350,48 +466,22 @@ class VerifyWindow(QtWidgets.QMainWindow):
self.info_label.setStyleSheet("color: #aaa; font-size: 11px;") self.info_label.setStyleSheet("color: #aaa; font-size: 11px;")
ctrl.addWidget(self.info_label) ctrl.addWidget(self.info_label)
def _setup_mpv(self): def _start_mpv(self):
"""Set up mpv player embedded in the video container widget.""" """Launch mpv in its own window, controlled via IPC socket."""
self.mpv_player = mpv.MPV( print(f"Launching mpv: {self.video_path}")
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."""
try: try:
dur = self.mpv_player.duration self.mpv = MpvController(self.video_path)
if dur and dur > 0: vid_dur = self.mpv.duration
self._duration_timer.stop() if vid_dur > 0:
vid_dur = float(dur)
self.info_label.setText( self.info_label.setText(
self.info_label.text().replace( self.info_label.text().replace(
f"Audio duration: {self.duration:.1f}s", f"Audio duration: {self.duration:.1f}s",
f"Video: {vid_dur:.1f}s" f"Video: {vid_dur:.1f}s"
) )
) )
except Exception: except Exception as e:
pass 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): def _draw_waveforms(self):
max_pts = 200_000 if self.mode == "full" else 80_000 max_pts = 200_000 if self.mode == "full" else 80_000
@@ -440,10 +530,12 @@ class VerifyWindow(QtWidgets.QMainWindow):
s = t % 60 s = t % 60
return f"{h:02d}:{m:02d}:{s:06.3f}" return f"{h:02d}:{m:02d}:{s:06.3f}"
def _sync_from_video(self): def _sync_from_mpv(self):
"""Timer callback: sync waveform playheads and seek bar to mpv position.""" """Poll mpv position and update waveforms."""
if not self.mpv:
return
try: try:
t = float(self.mpv_player.time_pos or 0) t = self.mpv.time_pos
except Exception: except Exception:
t = 0 t = 0
if t > self.duration: if t > self.duration:
@@ -451,9 +543,10 @@ class VerifyWindow(QtWidgets.QMainWindow):
self._set_pos(t) self._set_pos(t)
def toggle_play(self): def toggle_play(self):
if not self.mpv:
return
try: try:
paused = self.mpv_player.pause if not self.mpv.paused:
if paused is False:
self.stop_play() self.stop_play()
else: else:
self.start_play() self.start_play()
@@ -461,17 +554,14 @@ class VerifyWindow(QtWidgets.QMainWindow):
self.start_play() self.start_play()
def start_play(self): def start_play(self):
if self.duration <= 0: if self.duration <= 0 or not self.mpv:
return return
# Start video playback # Start video playback in mpv
self.mpv_player.pause = False self.mpv.play()
# Start audio mix via sounddevice # Start audio mix via sounddevice
try: t = self.mpv.time_pos
t = float(self.mpv_player.time_pos or 0)
except Exception:
t = 0
start_samp = int(t * self.sr) start_samp = int(t * self.sr)
if start_samp >= len(self.a_aligned): if start_samp >= len(self.a_aligned):
start_samp = 0 start_samp = 0
@@ -492,22 +582,18 @@ class VerifyWindow(QtWidgets.QMainWindow):
self.audio_player.play(mix, self.sr) self.audio_player.play(mix, self.sr)
self.playing_a_b = True self.playing_a_b = True
self.btn_play.setText("Pause") self.btn_play.setText("Pause")
self.sync_timer.start()
def stop_play(self): def stop_play(self):
self.mpv_player.pause = True if self.mpv:
self.mpv.pause()
self.audio_player.stop() self.audio_player.stop()
self.playing_a_b = False self.playing_a_b = False
self.btn_play.setText("Play") self.btn_play.setText("Play")
self.sync_timer.stop()
def _on_seek_start(self): def _on_seek_start(self):
self._seeking = True self._seeking = True
try: if self.mpv:
paused = self.mpv_player.pause self._was_playing = not self.mpv.paused
self._was_playing = (paused is False)
except Exception:
self._was_playing = False
if self._was_playing: if self._was_playing:
self.stop_play() self.stop_play()
@@ -515,12 +601,14 @@ class VerifyWindow(QtWidgets.QMainWindow):
frac = value / 1_000_000.0 frac = value / 1_000_000.0
t = frac * self.duration t = frac * self.duration
self._set_pos(t) self._set_pos(t)
self.mpv_player.seek(t, reference='absolute') if self.mpv:
self.mpv.seek(t)
def _on_seek_end(self): def _on_seek_end(self):
frac = self.seek.value() / 1_000_000.0 frac = self.seek.value() / 1_000_000.0
t = frac * self.duration t = frac * self.duration
self.mpv_player.seek(t, reference='absolute') if self.mpv:
self.mpv.seek(t)
self._set_pos(t) self._set_pos(t)
self._seeking = False self._seeking = False
if getattr(self, '_was_playing', False): if getattr(self, '_was_playing', False):
@@ -537,11 +625,8 @@ class VerifyWindow(QtWidgets.QMainWindow):
self.p_comb.setXRange(0, self.duration, padding=0) self.p_comb.setXRange(0, self.duration, padding=0)
def closeEvent(self, event): def closeEvent(self, event):
try: if self.mpv:
self.mpv_player.stop() self.mpv.terminate()
self.mpv_player.terminate()
except Exception:
pass
self.audio_player.stop() self.audio_player.stop()
self.sync_timer.stop() self.sync_timer.stop()
if self.temp_dir and os.path.isdir(self.temp_dir): 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) print(f"ERROR: wav B not found: {wav_b}", file=sys.stderr); sys.exit(1)
video_path = video_a video_path = video_a
# In short mode, use the extracted FFV1 video instead of the original HEVC source
if args.mode == "short": if args.mode == "short":
stem = os.path.splitext(os.path.basename(video_a))[0] stem = os.path.splitext(os.path.basename(video_a))[0]
extracted_video = os.path.join(out_dir_extract, f"{stem}_video.mkv") extracted_video = os.path.join(out_dir_extract, f"{stem}_video.mkv")