Add clap/onset auto-detection feature
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.
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user