Added automatic offset detection using the difference between the audio and video stream ends.
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user