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:
ThePendulum
2026-07-26 01:46:11 +02:00
parent f6e6f79a34
commit f7c15e924c
6 changed files with 468 additions and 1 deletions
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""
Detect sharp audio onsets (claps, snaps) in audio/video files.
Outputs timestamps in hh:mm:ss format.
"""
import sys
import argparse
import os
from datetime import timedelta
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from clap_detection import detect_claps, ClapDetectionUnavailable
def format_timestamp(seconds):
"""Convert seconds to hh:mm:ss.mmm format."""
td = timedelta(seconds=seconds)
total_seconds = int(td.total_seconds())
hours = total_seconds // 3600
minutes = (total_seconds % 3600) // 60
secs = td.total_seconds() % 60
return f"{hours:02d}:{minutes:02d}:{secs:06.3f}"
def main():
parser = argparse.ArgumentParser(
description='Detect claps/snaps in audio/video files',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s audio.wav
%(prog)s video.mp4 -t 0.7 -m 1.0
%(prog)s audio.wav -t 0.3 -s # Show onset strengths
Threshold guide:
0.3 = Very sensitive (detects most transients)
0.5 = Moderate (typical claps/snaps)
0.7 = Less sensitive (only loud, sharp claps)
0.9 = Very strict (only very loud events)
"""
)
parser.add_argument('input_file', help='Audio or video file')
parser.add_argument('-t', '--threshold', type=float, default=0.7,
help='Detection threshold (0-1, default: 0.7, higher = less sensitive)')
parser.add_argument('-m', '--min-interval', type=float, default=0.3,
help='Minimum seconds between detections (default: 0.3)')
parser.add_argument('-s', '--show-strength', action='store_true',
help='Show onset strength values')
args = parser.parse_args()
if not os.path.exists(args.input_file):
print(f"Error: File not found: {args.input_file}", file=sys.stderr)
sys.exit(1)
try:
events = detect_claps(
args.input_file,
threshold=args.threshold,
min_interval=args.min_interval,
)
except ClapDetectionUnavailable as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
print(f"\nFound {len(events)} onset(s):\n", file=sys.stderr)
for event in events:
timestamp = format_timestamp(event.time_sec)
if args.show_strength:
print(f"{timestamp} (strength: {event.strength:.2f})")
else:
print(timestamp)
if __name__ == '__main__':
main()
+86
View File
@@ -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()