- Clap detection moved from inline buttons to a Tools menu, plain labels
(no more '(whole file)' / '(from cursor)' parentheticals) -- status
messages moved to the window's status bar since the dedicated label
widget went away with the button row.
- ClickableSlider subclass: clicking anywhere along the seek track jumps
straight there instead of QSlider's default page-by-a-step behavior;
reposition-then-delegate-to-super keeps native drag behavior intact.
- Dragging now seeks live via sliderMoved instead of waiting for
sliderReleased.
- PlayerWidget accepts drag-and-drop of a local file to load it.
Verified with real Qt events (QMouseEvent clicks, a QDropEvent, a real
mouse click via xdotool opening the Tools menu on an actual running
window) rather than just calling the underlying methods directly.
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.
main.py wires up PlayerWidget with transport controls (play/pause,
frame-step buttons + ,/. shortcuts, seek slider), mute toggle, mark
video/audio buttons, live offset readout with manual override, and an
async export flow (QThread worker so ffmpeg doesn't block the UI) that
surfaces ffmpeg's stderr log without treating stream-copy warnings as
failures.
Verified two ways, not just by reading the code: a headless smoke test
that drives the real MainWindow/ExportWorker classes through a full
mark -> offset -> export cycle on the synthetic fixture, and a live
run (actual process, real X11 window, xdotool + import screenshot)
confirming the embedded mpv surface actually renders video inside the
Qt window rather than staying blank.
sync_logic.py: pure delay_ms computation with the documented sign
convention. Caught and fixed a sign error in my own test-fixture
ground-truth doc while writing the unit tests: the audio+350ms fixture
actually requires delay_ms=-350 (delay video), not +350, since the
audio is already the late one.
ffmpeg_export.py: ports ffdelay's two-branch command structure
(offset video input vs audio input depending on delay_ms sign) plus
its documented quirks (leading-zero float formatting, -ss/-t applied
to both inputs for clip mode, non-fatal stream-copy warnings surfaced
via stderr rather than swallowed).
Added an end-to-end integration test (scripts/test_export_roundtrip.py)
that independently re-detects event timing in the exported output via
ffmpeg scene-change/silence detection (not the same code path used to
build the export) and confirms both the positive- and negative-delay
branches bring drift down from ~350ms to under 1ms.
Confirms single-frame stepping is reliable: monotonic frame counter
across steps, and the synthetic clip's 1-frame flash event lands on
exactly one frame with clean neighbors. Also documents/works around
three environment quirks hit along the way: locally-built libmpv not
in ldconfig's cache, QApplication resetting LC_NUMERIC and breaking
libmpv's locale requirement, and the playback-restart event not firing
reliably after frame-step (switched to polling estimated_frame_number).