refactor: replace QMediaPlayer with mpv for video playback

This commit is contained in:
Honney OC committed 2026-09-13 23:28:15 +00:00
1 parent 53b22ce25d
commit 2c2f6fe839
2 files changed
+81 -114

No files matched your search

+2 -2
View File
@@ -51,8 +51,8 @@ install:
fi fi
@echo "=> installing common deps..." @echo "=> installing common deps..."
@$(PIP_INSTALL) soundfile scipy numpy pyqtgraph PyQt6 || echo " common deps failed" @$(PIP_INSTALL) soundfile scipy numpy pyqtgraph PyQt6 || echo " common deps failed"
@echo "=> optional: sounddevice (playback), openai-whisper (language detection)..." @echo "=> optional: sounddevice (playback), openai-whisper (language detection), mpv (video player)..."
@$(PIP_INSTALL) sounddevice openai-whisper || echo " optional deps failed (sounddevice/whisper)" @$(PIP_INSTALL) sounddevice openai-whisper python-mpv || echo " optional deps failed (sounddevice/whisper/mpv)"
@$(PY) -c "import torch, sys; print(f'torch {torch.__version__} cuda_available={torch.cuda.is_available()}', end=''); print(f' device={torch.cuda.get_device_name(0)}' if torch.cuda.is_available() else ' (cpu)')" 2>&1 || echo "torch not available, using numpy CPU fallback (still functional for 90s xcorr)" @$(PY) -c "import torch, sys; print(f'torch {torch.__version__} cuda_available={torch.cuda.is_available()}', end=''); print(f' device={torch.cuda.get_device_name(0)}' if torch.cuda.is_available() else ' (cpu)')" 2>&1 || echo "torch not available, using numpy CPU fallback (still functional for 90s xcorr)"
@echo "=> done. Activate: source $(VENV)/bin/activate" @echo "=> done. Activate: source $(VENV)/bin/activate"
+79 -112
View File
@@ -1,13 +1,13 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Verify sync: QMediaPlayer video player + dual waveform display. Verify sync: mpv video player + 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 QMediaPlayer/QVideoWidget - Smooth video playback via mpv (handles all codecs natively)
- 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
@@ -34,12 +34,20 @@ except ModuleNotFoundError:
try: try:
import pyqtgraph as pg import pyqtgraph as pg
from PyQt6 import QtWidgets, QtCore, QtGui, QtMultimedia, QtMultimediaWidgets from PyQt6 import QtWidgets, QtCore, QtGui
HAS_PG = True 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
@@ -108,37 +116,6 @@ def extract_audio_from_mkv(mkv_path: str, stream_index: int, out_wav: str, sr: i
return False return False
def transcode_to_h264_sdr(src: str, dst: str, duration: float = 0) -> bool:
"""Transcode video to H.264 SDR MP4 for QMediaPlayer compatibility.
Tries VAAPI hardware encoding first, falls back to CPU x264."""
for encoder in ["h264_vaapi", "libx264"]:
cmd = ["ffmpeg", "-y", "-i", src]
if duration > 0:
cmd += ["-t", f"{duration:.3f}"]
cmd += ["-map", "0:v:0", "-an"]
if encoder == "h264_vaapi":
cmd += ["-vaapi_device", "/dev/dri/renderD128",
"-vf", "format=nv12,hwupload",
"-c:v", "h264_vaapi", "-qp", "20"]
else:
cmd += ["-c:v", "libx264", "-preset", "ultrafast", "-crf", "20",
"-pix_fmt", "yuv420p",
"-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2"]
cmd += ["-movflags", "+faststart", dst]
try:
r = subprocess.run(cmd, capture_output=True, timeout=600)
if r.returncode == 0 and os.path.exists(dst):
print(f" encoded with {encoder}")
return True
print(f" {encoder} failed, trying next...", file=sys.stderr)
if os.path.exists(dst):
os.remove(dst)
except Exception:
if os.path.exists(dst):
os.remove(dst)
return False
def apply_lag(a: np.ndarray, b: np.ndarray, lag_samples: int) -> tuple[np.ndarray, np.ndarray]: def apply_lag(a: np.ndarray, b: np.ndarray, lag_samples: int) -> tuple[np.ndarray, np.ndarray]:
if lag_samples >= 0: if lag_samples >= 0:
a_out = a[lag_samples:] a_out = a[lag_samples:]
@@ -219,7 +196,7 @@ class VerifyWindow(QtWidgets.QMainWindow):
self.audio_player = AudioPlayer() self.audio_player = AudioPlayer()
self.default_volume = volume self.default_volume = volume
self.duration = 0.0 self.duration = 0.0
self.playing_a_b = False # our custom audio mix playing self.playing_a_b = False
title = "verify_sync" title = "verify_sync"
if mode == "short": if mode == "short":
@@ -242,9 +219,7 @@ class VerifyWindow(QtWidgets.QMainWindow):
self._setup_ui() self._setup_ui()
self._draw_waveforms() self._draw_waveforms()
self._setup_mpv()
# Set up QMediaPlayer for video
self._setup_video_player()
def _setup_ui(self): def _setup_ui(self):
central = QtWidgets.QWidget() central = QtWidgets.QWidget()
@@ -252,7 +227,7 @@ class VerifyWindow(QtWidgets.QMainWindow):
lay = QtWidgets.QVBoxLayout(central) lay = QtWidgets.QVBoxLayout(central)
lay.setSpacing(2) lay.setSpacing(2)
# ── Video display (QWidget container) ── # ── Video display (QWidget container for mpv) ──
self.video_container = QtWidgets.QWidget() self.video_container = QtWidgets.QWidget()
self.video_container.setMinimumHeight(300) self.video_container.setMinimumHeight(300)
self.video_container.setStyleSheet("background: #000;") self.video_container.setStyleSheet("background: #000;")
@@ -317,10 +292,6 @@ class VerifyWindow(QtWidgets.QMainWindow):
self.p_a.setXLink(self.p_comb) self.p_a.setXLink(self.p_comb)
self.p_b.setXLink(self.p_comb) self.p_b.setXLink(self.p_comb)
# Disable auto-range on all plots to prevent overflow on linked axes
self.p_comb.getViewBox().setAutoVisible(y=False)
self.p_a.getViewBox().setAutoVisible(y=False)
self.p_b.getViewBox().setAutoVisible(y=False)
for p in [self.p_comb, self.p_a, self.p_b]: for p in [self.p_comb, self.p_a, self.p_b]:
p.enableAutoRange(axis='x', enable=False) p.enableAutoRange(axis='x', enable=False)
p.enableAutoRange(axis='y', enable=False) p.enableAutoRange(axis='y', enable=False)
@@ -379,52 +350,48 @@ 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_video_player(self): def _setup_mpv(self):
"""Set up QMediaPlayer with QVideoWidget for smooth video playback.""" """Set up mpv player embedded in the video container widget."""
self.media_player = QtMultimedia.QMediaPlayer() self.mpv_player = mpv.MPV(
self.video_output = QtMultimediaWidgets.QVideoWidget() wid=str(int(self.video_container.winId())),
# Place QVideoWidget inside our container input_default_bindings=False,
layout = QtWidgets.QVBoxLayout(self.video_container) input_vo_keyboard=False,
layout.setContentsMargins(0, 0, 0, 0) cursor_autohide=False,
layout.addWidget(self.video_output) keep_open=True,
osc=False,
osd_bar=False,
)
# Mute mpv audio — we play audio via sounddevice
self.mpv_player.audio = "no"
self.media_player.setVideoOutput(self.video_output) self.mpv_player.play(os.path.abspath(self.video_path))
# Mute the video player's own audio — we play audio via sounddevice
self.audio_output = QtMultimedia.QAudioOutput()
self.audio_output.setMuted(True)
self.media_player.setAudioOutput(self.audio_output)
# Load the video file # Timer to sync waveform playheads with mpv position
from PyQt6.QtCore import QUrl
self.media_player.setSource(QUrl.fromLocalFile(os.path.abspath(self.video_path)))
# Timer to sync waveform playheads with video position
self.sync_timer = QtCore.QTimer() self.sync_timer = QtCore.QTimer()
self.sync_timer.setInterval(33) # ~30fps self.sync_timer.setInterval(33) # ~30fps
self.sync_timer.timeout.connect(self._sync_from_video) self.sync_timer.timeout.connect(self._sync_from_video)
self.media_player.playbackStateChanged.connect(self._on_playback_state) # 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()
# Wait for media loaded to get actual duration def _wait_for_duration(self):
self.media_player.durationChanged.connect(self._on_duration_changed) """Poll mpv until duration is available."""
try:
def _on_duration_changed(self, dur_ms): dur = self.mpv_player.duration
"""QMediaPlayer reports actual video duration in ms.""" if dur and dur > 0:
vid_dur = dur_ms / 1000.0 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:
pass
def _on_playback_state(self, state):
if state == QtMultimedia.QMediaPlayer.PlaybackState.StoppedState:
self.sync_timer.stop()
self.playing_a_b = False
self.btn_play.setText("Play")
self.audio_player.stop()
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
@@ -474,18 +441,23 @@ class VerifyWindow(QtWidgets.QMainWindow):
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_video(self):
"""Timer callback: sync waveform playheads and seek bar to video position.""" """Timer callback: sync waveform playheads and seek bar to mpv position."""
pos_ms = self.media_player.position() try:
t = pos_ms / 1000.0 t = float(self.mpv_player.time_pos or 0)
except Exception:
t = 0
if t > self.duration: if t > self.duration:
t = self.duration t = self.duration
self._set_pos(t) self._set_pos(t)
def toggle_play(self): def toggle_play(self):
state = self.media_player.playbackState() try:
if state == QtMultimedia.QMediaPlayer.PlaybackState.PlayingState: paused = self.mpv_player.pause
self.stop_play() if paused is False:
else: self.stop_play()
else:
self.start_play()
except Exception:
self.start_play() self.start_play()
def start_play(self): def start_play(self):
@@ -493,10 +465,13 @@ class VerifyWindow(QtWidgets.QMainWindow):
return return
# Start video playback # Start video playback
self.media_player.play() self.mpv_player.pause = False
# Start audio mix via sounddevice # Start audio mix via sounddevice
t = self.media_player.position() / 1000.0 try:
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
@@ -520,7 +495,7 @@ class VerifyWindow(QtWidgets.QMainWindow):
self.sync_timer.start() self.sync_timer.start()
def stop_play(self): def stop_play(self):
self.media_player.pause() self.mpv_player.pause = True
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")
@@ -528,21 +503,24 @@ class VerifyWindow(QtWidgets.QMainWindow):
def _on_seek_start(self): def _on_seek_start(self):
self._seeking = True self._seeking = True
was_playing = self.media_player.playbackState() == QtMultimedia.QMediaPlayer.PlaybackState.PlayingState try:
if was_playing: paused = self.mpv_player.pause
self._was_playing = (paused is False)
except Exception:
self._was_playing = False
if self._was_playing:
self.stop_play() self.stop_play()
self._was_playing = was_playing
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.media_player.setPosition(int(t * 1000)) self.mpv_player.seek(t, reference='absolute')
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.media_player.setPosition(int(t * 1000)) self.mpv_player.seek(t, reference='absolute')
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):
@@ -559,7 +537,11 @@ 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):
self.media_player.stop() try:
self.mpv_player.stop()
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):
@@ -663,21 +645,6 @@ def main():
print(f"WARNING: extracted video not found ({extracted_video}), using original", file=sys.stderr) print(f"WARNING: extracted video not found ({extracted_video}), using original", file=sys.stderr)
temp_dir = None temp_dir = None
# Always transcode to H.264 SDR — QMediaPlayer can't reliably decode
# HEVC HDR/DV, FFV1 yuv444p, or other exotic codecs
temp_dir = temp_dir or tempfile.mkdtemp(prefix="verify_sync_")
preview_path = os.path.join(temp_dir, "preview.mp4")
if not os.path.exists(preview_path):
transcode_dur = 30.0 if args.mode == "short" else 0
print(f"Transcoding to H.264 SDR ({'%.1fs' % transcode_dur if transcode_dur else 'full'})...")
if not transcode_to_h264_sdr(video_path, preview_path, transcode_dur):
print(f"ERROR: transcode failed", file=sys.stderr)
sys.exit(1)
print(f"Preview ready: {preview_path}")
else:
print(f"Using cached preview: {preview_path}")
video_path = preview_path
print(f"Mode: {args.mode}") print(f"Mode: {args.mode}")
print(f"Video: {video_path}") print(f"Video: {video_path}")
print(f"WAV A: {wav_a}") print(f"WAV A: {wav_a}")