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
|
#!/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,35 +582,33 @@ 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)
|
if self._was_playing:
|
||||||
except Exception:
|
self.stop_play()
|
||||||
self._was_playing = False
|
|
||||||
if self._was_playing:
|
|
||||||
self.stop_play()
|
|
||||||
|
|
||||||
def _on_seek_move(self, value):
|
def _on_seek_move(self, value):
|
||||||
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")
|
||||||
|
|||||||
Reference in new issue
Block a user