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:
Executable
+79
@@ -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()
|
||||
Reference in New Issue
Block a user