Ports scripts/detect_claps.py (a librosa-based onset detector from an earlier session) into clap_detection.py, shared by both the CLI script and a new GUI feature: 'Detect All Claps' (whole-file scan) and 'Detect Next Clap' (from cursor, first result only), with results shown as clickable markers on a new MarkerStrip widget above the seek slider -- clicking one seeks the player to that exact timestamp. Caught a real correctness bug while testing against the out-of-sync fixture (not just the plain in-sync one): ffmpeg's default -vn audio extraction silently drops the audio stream's own start_time and renormalizes to t=0 -- which is exactly the offset this whole tool exists to fix, so losing it meant detected timestamps pointed at the wrong track's timing entirely. Fixed by probing the true start_time via ffprobe and re-inserting it as explicit silence (adelay) before analysis. Also dropped an earlier attempt at an ffmpeg -ss seek optimization for the from-cursor case, since combining it with the start_time fix meant two independent timestamp-origin corrections stacked on each other -- correct in theory, too easy to get subtly wrong in a tool where a silent wrong-timestamp bug is the worst possible failure mode. Full-file decode + filter in Python is slower but unambiguous. Verified with a smoke test that drives the real MainWindow buttons, confirms the detected marker lands near the fixture's known beep (t=5.350s, allowing for onset-detection lag), and simulates an actual QMouseEvent click on the marker to confirm click-to-seek works end-to-end.
69 lines
2.1 KiB
Python
69 lines
2.1 KiB
Python
from typing import List
|
|
|
|
from PyQt6.QtCore import Qt, QPointF, pyqtSignal
|
|
from PyQt6.QtGui import QPainter, QColor, QPolygonF
|
|
from PyQt6.QtWidgets import QWidget
|
|
|
|
MARKER_COLOR = QColor(230, 126, 34) # orange
|
|
CLICK_TOLERANCE_PX = 8.0
|
|
|
|
|
|
class MarkerStrip(QWidget):
|
|
"""Thin strip drawn directly above the seek slider (same width, via a
|
|
shared parent layout) showing detected clap/onset markers as small
|
|
triangles. Clicking one seeks the player to that exact timestamp.
|
|
"""
|
|
|
|
markerClicked = pyqtSignal(float)
|
|
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent)
|
|
self.setFixedHeight(14)
|
|
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
self._duration = 0.0
|
|
self._markers: List[float] = []
|
|
|
|
def set_duration(self, duration: float):
|
|
self._duration = duration
|
|
self.update()
|
|
|
|
def set_markers(self, markers: List[float]):
|
|
self._markers = sorted(markers)
|
|
self.update()
|
|
|
|
def markers(self) -> List[float]:
|
|
return list(self._markers)
|
|
|
|
def clear(self):
|
|
self._markers = []
|
|
self.update()
|
|
|
|
def _x_for_time(self, t: float) -> float:
|
|
if self._duration <= 0:
|
|
return 0.0
|
|
return (t / self._duration) * self.width()
|
|
|
|
def paintEvent(self, event):
|
|
if not self._markers or self._duration <= 0:
|
|
return
|
|
painter = QPainter(self)
|
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
painter.setPen(Qt.PenStyle.NoPen)
|
|
painter.setBrush(MARKER_COLOR)
|
|
for t in self._markers:
|
|
x = self._x_for_time(t)
|
|
triangle = QPolygonF([
|
|
QPointF(x - 5, 0),
|
|
QPointF(x + 5, 0),
|
|
QPointF(x, 10),
|
|
])
|
|
painter.drawPolygon(triangle)
|
|
|
|
def mousePressEvent(self, event):
|
|
if not self._markers or self._duration <= 0:
|
|
return
|
|
click_x = event.position().x()
|
|
closest = min(self._markers, key=lambda t: abs(self._x_for_time(t) - click_x))
|
|
if abs(self._x_for_time(closest) - click_x) <= CLICK_TOLERANCE_PX:
|
|
self.markerClicked.emit(closest)
|