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,86 @@
|
||||
"""Drives the real MainWindow's clap-detection buttons (not a copy of the
|
||||
logic) against the synthetic fixture with a known beep at t=5.350s, then
|
||||
simulates a real mouse click on the MarkerStrip widget to confirm
|
||||
click-to-seek actually moves the player -- not just that a signal exists.
|
||||
"""
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt6.QtCore import Qt, QPointF
|
||||
from PyQt6.QtGui import QMouseEvent
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
import main as app_main
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
CLIP = str(ROOT / "test_media" / "sync_test_OUTOFSYNC_audio+350ms.mp4")
|
||||
OUT_DIR = ROOT / "test_media" / "seek_validation"
|
||||
|
||||
|
||||
def wait_for(predicate, qapp, timeout=20):
|
||||
deadline = time.time() + timeout
|
||||
while not predicate() and time.time() < deadline:
|
||||
qapp.processEvents()
|
||||
time.sleep(0.05)
|
||||
return predicate()
|
||||
|
||||
|
||||
def main():
|
||||
OUT_DIR.mkdir(exist_ok=True)
|
||||
qapp = QApplication(sys.argv)
|
||||
win = app_main.MainWindow()
|
||||
win.show()
|
||||
qapp.processEvents()
|
||||
|
||||
win._load(CLIP)
|
||||
qapp.processEvents()
|
||||
time.sleep(0.3)
|
||||
qapp.processEvents()
|
||||
|
||||
assert win.detect_all_button.isEnabled()
|
||||
win.detect_all_button.click()
|
||||
qapp.processEvents()
|
||||
assert not win.detect_all_button.isEnabled(), "button should disable while detection runs"
|
||||
|
||||
ok = wait_for(lambda: win.detect_all_button.isEnabled(), qapp)
|
||||
assert ok, "detection did not finish in time"
|
||||
|
||||
markers = win.marker_strip.markers()
|
||||
print(f"detected markers: {markers}")
|
||||
assert len(markers) >= 1, "expected at least one clap marker"
|
||||
# ground truth beep is at t=5.350s in this fixture; onset detection has
|
||||
# some inherent lag, so allow a generous window rather than exact match.
|
||||
assert any(abs(m - 5.350) < 0.5 for m in markers), f"no marker near the known beep: {markers}"
|
||||
|
||||
win.grab().save(str(OUT_DIR / "clap_markers_on_timeline.png"))
|
||||
|
||||
# Simulate a real mouse click on the marker to prove click-to-seek works
|
||||
# end to end, not just that the signal is wired.
|
||||
target_time = min(markers, key=lambda m: abs(m - 5.350))
|
||||
strip = win.marker_strip
|
||||
strip.resize(win.seek_slider.width(), strip.height())
|
||||
x = strip._x_for_time(target_time)
|
||||
click_pos = QPointF(x, 5)
|
||||
|
||||
before_pos = win.player.get_position()
|
||||
event = QMouseEvent(
|
||||
QMouseEvent.Type.MouseButtonPress, click_pos, click_pos,
|
||||
Qt.MouseButton.LeftButton, Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier,
|
||||
)
|
||||
strip.mousePressEvent(event)
|
||||
qapp.processEvents()
|
||||
time.sleep(0.2)
|
||||
qapp.processEvents()
|
||||
|
||||
after_pos = win.player.get_position()
|
||||
print(f"clicked marker at x={x:.1f} (t={target_time:.3f}s); player position before={before_pos:.3f}s after={after_pos:.3f}s")
|
||||
assert abs(after_pos - target_time) < 0.1, "clicking the marker should seek the player to that timestamp"
|
||||
|
||||
win.player.shutdown()
|
||||
print("\nCLAP DETECTION SMOKE TEST PASSED")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user