Files
ThePendulum f7c15e924c 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.
2026-07-26 01:46:11 +02:00

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)