Added automatic offset detection using the difference between the audio and video stream ends.

This commit is contained in:
ThePendulum
2026-09-29 21:50:24 +02:00
parent cffc26d98e
commit e6f3f370da
6 changed files with 423 additions and 4 deletions
+118 -3
View File
@@ -19,6 +19,7 @@ from clap_detection import (
detect_claps, ClapDetectionUnavailable, ClapEvent,
DEFAULT_THRESHOLD, DEFAULT_MIN_INTERVAL,
)
from stream_edges import probe_stream_edges, StreamEdges, StreamEdgesError
class ClapSettingsDialog(QDialog):
@@ -122,6 +123,28 @@ class DetectWorker(QThread):
self.finished_err.emit(str(e))
class EstimateWorker(QThread):
finished_ok = pyqtSignal(object) # StreamEdges
finished_err = pyqtSignal(str)
def __init__(self, input_path, video_stream_index=None, audio_stream_index=None):
super().__init__()
self.input_path = input_path
self.video_stream_index = video_stream_index
self.audio_stream_index = audio_stream_index
def run(self):
try:
edges = probe_stream_edges(
self.input_path,
video_stream_index=self.video_stream_index,
audio_stream_index=self.audio_stream_index,
)
self.finished_ok.emit(edges)
except StreamEdgesError as e:
self.finished_err.emit(str(e))
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
@@ -131,9 +154,13 @@ class MainWindow(QMainWindow):
self.input_path: Optional[str] = None
self.video_mark: Optional[float] = None
self.audio_mark: Optional[float] = None
# The offset currently feeding Preview/Export -- derived either from
# the two markers or from the stream-ends estimate, whichever was
# applied last.
self.offset: Optional[SyncOffset] = None
self._export_worker: Optional[ExportWorker] = None
self._detect_worker: Optional[DetectWorker] = None
self._estimate_worker: Optional[EstimateWorker] = None
self._preview_worker: Optional[ExportWorker] = None
self._preview_temp_path: Optional[str] = None # currently loaded in preview_panel
self._pending_preview_path: Optional[str] = None # being built by _preview_worker
@@ -187,6 +214,13 @@ class MainWindow(QMainWindow):
self.video_mark_label = QLabel("video: —")
marker_layout.addWidget(self.video_mark_label)
# Re-applies the marker-derived offset after the stream-ends
# estimate replaced it, without having to re-mark an event.
self.use_marker_offset_button = QPushButton("Use Marker Offset")
self.use_marker_offset_button.setEnabled(False)
self.use_marker_offset_button.clicked.connect(self._recompute_offset)
marker_layout.addWidget(self.use_marker_offset_button)
marker_layout.addStretch(1)
self.detect_all_button = QPushButton("Detect All Claps")
@@ -208,6 +242,11 @@ class MainWindow(QMainWindow):
self.offset_label = QLabel("Offset: — (mark both events)")
export_layout.addWidget(self.offset_label, stretch=1)
self.estimate_button = QPushButton("Estimate from Stream Ends")
self.estimate_button.setEnabled(False)
self.estimate_button.clicked.connect(self._on_estimate_clicked)
export_layout.addWidget(self.estimate_button)
export_layout.addWidget(QLabel("Override (ms):"))
self.override_spinbox = QDoubleSpinBox()
self.override_spinbox.setRange(-600000, 600000)
@@ -263,6 +302,13 @@ class MainWindow(QMainWindow):
tools_menu.addSeparator()
self.estimate_action = QAction("Estimate Offset from Stream Ends", self)
self.estimate_action.setEnabled(False)
self.estimate_action.triggered.connect(self._on_estimate_clicked)
tools_menu.addAction(self.estimate_action)
tools_menu.addSeparator()
settings_action = QAction("Clap Detection Settings…", self)
settings_action.triggered.connect(self._on_clap_settings_clicked)
tools_menu.addAction(settings_action)
@@ -315,6 +361,7 @@ class MainWindow(QMainWindow):
self.export_button.setEnabled(False)
self.preview_button.setEnabled(False)
self.override_spinbox.setEnabled(False)
self.use_marker_offset_button.setEnabled(False)
self.log_view.clear()
self.setWindowTitle(f"WeAreSyncing — {Path(path).name}")
@@ -323,6 +370,8 @@ class MainWindow(QMainWindow):
self.detect_all_button.setEnabled(True)
self.detect_next_action.setEnabled(True)
self.detect_next_button.setEnabled(True)
self.estimate_action.setEnabled(True)
self.estimate_button.setEnabled(True)
# A loaded preview belongs to the *previous* source/offset -- drop it.
if self._preview_temp_path and os.path.exists(self._preview_temp_path):
@@ -350,12 +399,71 @@ class MainWindow(QMainWindow):
if self.video_mark is None or self.audio_mark is None:
return
self.offset = compute_offset(self.video_mark, self.audio_mark)
self.offset_label.setText(self.offset.describe())
self.override_spinbox.setValue(self.offset.delay_ms)
self._apply_offset(self.offset, self.offset.describe())
self.use_marker_offset_button.setEnabled(True)
def _apply_offset(self, offset: SyncOffset, label: str):
"""Shared by both offset sources (markers, stream-ends estimate):
whichever is applied last is what Preview/Export use."""
self.offset = offset
self.offset_label.setText(label)
self.override_spinbox.setValue(offset.delay_ms)
self.override_spinbox.setEnabled(True)
self.export_button.setEnabled(True)
self.preview_button.setEnabled(True)
# -- stream-ends estimate ----------------------------------------------
def _current_video_stream_index(self) -> Optional[int]:
for track in self.source_panel.player.mpv.track_list:
if track.get("type") == "video" and track.get("selected"):
return track.get("ff-index")
return None
def _on_estimate_clicked(self):
if self.input_path is None:
return
self.estimate_action.setEnabled(False)
self.estimate_button.setEnabled(False)
self.statusBar().showMessage("Estimating offset from stream ends…")
self._estimate_worker = EstimateWorker(
self.input_path,
video_stream_index=self._current_video_stream_index(),
audio_stream_index=self._current_audio_stream_index(),
)
self._estimate_worker.finished_ok.connect(self._on_estimate_done)
self._estimate_worker.finished_err.connect(self._on_estimate_failed)
self._estimate_worker.start()
def _on_estimate_done(self, edges: StreamEdges):
self.estimate_action.setEnabled(True)
self.estimate_button.setEnabled(True)
offset = SyncOffset(delay_ms=round(edges.end_delay_ms))
self._apply_offset(offset, f"{offset.describe()} — estimated from stream ends")
self.log_view.appendPlainText(
f"Stream-ends estimate: video {edges.video_start:.3f}–{edges.video_end:.3f}s, "
f"audio {edges.audio_start:.3f}–{edges.audio_end:.3f}s → "
f"end-based {edges.end_delay_ms:+.0f} ms, start-based {edges.start_delay_ms:+.0f} ms"
)
if edges.estimates_disagree:
message = (
f"Estimated {offset.delay_ms:+.0f} ms from stream ends, but stream starts suggest "
f"{edges.start_delay_ms:+.0f} ms — verify with Preview."
)
self.log_view.appendPlainText("WARNING: " + message)
else:
message = f"Estimated {offset.delay_ms:+.0f} ms from stream ends (assumes both tracks stopped recording together) — verify with Preview."
self.statusBar().showMessage(message, 10000)
def _on_estimate_failed(self, message: str):
self.estimate_action.setEnabled(True)
self.estimate_button.setEnabled(True)
self.statusBar().showMessage("Offset estimate failed (see log).", 8000)
self.log_view.appendPlainText(f"Stream-ends estimate FAILED: {message}")
# -- preview -----------------------------------------------------
PREVIEW_MARGIN_SEC = 3.0
@@ -373,7 +481,14 @@ class MainWindow(QMainWindow):
# keeps both the video and audio events (and wherever the offset
# moves them to) inside the window for typical human-scale marking
# errors.
center = (self.video_mark + self.audio_mark) / 2.0
#
# An estimated offset may have no markers behind it; center on the
# Source playhead instead, so the user can park it on a sync event
# (or any stretch of speech) before previewing.
if self.video_mark is not None and self.audio_mark is not None:
center = (self.video_mark + self.audio_mark) / 2.0
else:
center = self.source_panel.player.get_position()
clip_start = max(center - self.PREVIEW_MARGIN_SEC, 0.0)
input_p = Path(self.input_path)