24 Commits
Author SHA1 Message Date
ThePendulum a1d2b846fd Added AI disclaimer to README. 2026-09-29 21:54:01 +02:00
ThePendulum 30dc340a10 Added README. 2026-09-29 21:52:23 +02:00
ThePendulum e6f3f370da Added automatic offset detection using the difference between the audio and video stream ends. 2026-09-29 21:50:24 +02:00
ThePendulum cffc26d98e Suggest ffdelay-style filenames on export (_delayedNms)
Export used to suggest "<name>_synced<ext>", losing the offset value
itself. Restored the original ffdelay script's naming convention
(_delayed500ms / _delayed-500ms) so the applied offset is visible in
the filename, matching what the CLI tool already did before this GUI
existed.
2026-07-28 00:54:33 +02:00
ThePendulum ff127a8c21 Add clap detection sensitivity settings (Tools menu)
Threshold and min-interval (the librosa delta/wait knobs) were only
ever tunable via the CLI's -t/-m flags. Added a Clap Detection
Settings dialog (Tools menu) with both as spin boxes, an explicit
Reset to Defaults button, and Cancel that leaves MainWindow's current
settings untouched.

Exposed DEFAULT_THRESHOLD=0.7/DEFAULT_MIN_INTERVAL=0.3 as named
constants in clap_detection.py so the CLI script, DetectWorker's
defaults, and the dialog's reset button all reference the same source
of truth instead of three copies of the same magic numbers that could
drift apart.
2026-07-26 05:56:50 +02:00
ThePendulum 97cbf38d97 Fix clap detection silently closing mid-recording audio gaps
Real bug found on the user's actual file (a ~110min recorded capture),
not reproducible with any of our synthetic fixtures: plain ffmpeg -vn
extraction just concatenates decoded audio packets and ignores their
PTS, so a genuine multi-second discontinuity mid-recording (e.g. a
brief audio-device dropout during a long capture session -- common in
OBS-style long sessions, unrelated to A/V sync) gets silently closed
up instead of preserved as a gap. Every onset detected after that
point then gets reported however much earlier the gap was, while
onsets before it stay correct -- exactly matching the reported
symptom: dead-on for some files, off by several seconds on others,
with the 'sort of sound' being irrelevant since it's not about
detection quality at all.

Diagnosed by scanning the real file's audio packet timeline for
non-uniform PTS spacing: found exactly one jump, +1.621s at ~928s in,
matching (almost to the millisecond) the 1.6s gap between the source's
true duration and what plain -vn extraction produced. Fixed by adding
aresample=async=1 to the extraction filter chain, which conforms
decoded audio to its own PTS and fills gaps with silence instead of
dropping them -- confirmed the extracted WAV's duration now matches
the source exactly.

This is a different failure mode from the leading start_time offset
already handled (aresample=async=1 alone does NOT fix that case -- it
re-zeros to its own first frame same as plain extraction, confirmed
by testing on the existing OUTOFSYNC fixture), so both fixes now run
together in the same filter chain. Verified no regression on every
existing single-track, leading-offset, and multi-track fixture.
2026-07-26 05:28:17 +02:00
ThePendulum 76b5f827be Fix clap detection silently analyzing the wrong audio track
Root cause of the reported 'timestamps off by different amounts,
sometimes early, sometimes late' bug: _extract_audio_wav() never
passed an explicit -map to ffmpeg, so on multi-audio-track files it
relied on ffmpeg's own default-stream heuristic -- which has no
knowledge of, and isn't guaranteed to agree with, whichever audio
track mpv is actually configured to play. A wrong-track clap is
essentially unrelated to the true one (not a small timestamp-math
error), which explains the inconsistent, file-dependent symptom --
and our single-audio-track synthetic fixtures never exercised this
path at all, since they only ever had one track to pick.

Fixed by threading an explicit ffmpeg stream index through
detect_claps() -> _extract_audio_wav(), sourced in main.py from mpv's
own track-list (the ff-index of whichever audio track has
selected=True). Detection now always analyzes the exact track the
user is listening to, on both 'Detect All' and 'Detect Next'.

Also: _probe_audio_start_time() used to silently fall back to 0.0 if
ffprobe couldn't parse start_time as a plain float (e.g. N/A) -- same
failure class (silently wrong instead of loud). Now emits a Python
warning instead, so a genuinely unparseable/unusual container is at
least visible rather than silently mis-corrected.

Verified two ways: (1) ruled out an initial alternate hypothesis
(video stream's own start_time mattering) empirically against the
existing single-track fixtures before ruling it out. (2) Built a new
two-audio-track fixture (sync_test_MULTITRACK.mp4: track 1 has a beep
at 3s, track 2 at 7s) and confirmed end-to-end through the real app --
switching mpv's active track from aid=1 to aid=2 correctly flips which
clap gets detected, which is exactly the mechanism this bug required.
2026-07-26 05:10:12 +02:00
ThePendulum acd63f570d Polish transport spacing, drop shortcut hints, add Detect Next Clap button
- PlayerPanel now leaves an 8px bottom margin so the transport row
  isn't flush against the tab's edge.
- Frame-step buttons read '⏮ Frame' / 'Frame ⏭' instead of
  '⏮ Frame (,)' / 'Frame ⏭ (.)' -- the shortcuts still work, just not
  spelled out on the buttons.
- Detect Next Clap is now a button in the Sync markers panel next to
  Detect All Claps (still also in the Tools menu), kept in sync with
  its menu action's enabled state the same way Detect All Claps was.
2026-07-26 04:19:55 +02:00
ThePendulum 8c34a0f0c2 Give each tab its own full timeline and transport (PlayerPanel refactor)
Extracts a new PlayerPanel widget (video + its own seek slider/marker
strip + its own centered frame-step/play/mute transport row) into
player_panel.py. Both Source and Preview tabs now use one, so
switching tabs switches a complete, independent monitor -- its own
scrubber, its own play state, its own frame-stepping -- not just the
video frame with a single shared play button underneath.

main.py's global keyboard shortcuts (,/./space/left/right) now
dispatch to whichever panel is active via a small _active_panel()
helper, so they follow the same 'whichever monitor you're looking at'
rule as the on-screen controls.

MarkerStrip (clap detection) and the Mark Audio/Video/offset/export
row stay outside the tabs, at the MainWindow level -- marking and
exporting are inherently about the source file, not the preview clip,
so only the timeline+transport moved, not the whole marking workflow.

Updated every existing smoke test for the new win.source_panel.* /
win.preview_panel.* structure (previously flat win.player,
win.seek_slider, etc.), and added a new check to
smoke_test_preview.py confirming the Preview tab's slider is a
genuinely separate, functional widget -- dragging it seeks the preview
player without moving the source player, which is the actual thing
this change was for.

Verified the full suite on the virtual Xvfb display, plus a live
click-through (mark both events, build preview, confirm the Preview
tab shows its own populated timeline/transport, not the source's).
2026-07-26 03:58:23 +02:00
ThePendulum 0a8f9f45bd Replace preview popup with Kdenlive-style Source/Preview tabs
Removes PreviewDialog; the video area is now a QTabWidget with two
tabs, each backed by its own independent, long-lived PlayerWidget/mpv
instance:
- Source: the main player (unchanged transport/marking controls below
  it still apply here).
- Preview: a second player with its own minimal transport (play/pause
  + position label), auto-plays and switches to this tab as soon as a
  preview clip finishes building.

The preview player persists for the whole session instead of being
created per-preview -- building a new preview loads the new clip into
the same mpv instance and only deletes the old temp file afterward
(unsafe to unlink while mpv still has it open). Loading a new source
file drops any existing preview, since it belonged to the previous
file's offset.

Found and fixed a real crash bug while testing the close-window path
(not something the earlier popup-based tests exercised): closeEvent
shut down both mpv instances but left the 100ms position-update QTimer
running, so a tick landing between shutdown and process exit called
get_position() on an already-terminated core and hard-crashed the
process (native abort, not a catchable Python exception). Fixed by
stopping the timer first in closeEvent.

Verified on the virtual Xvfb display: automated test covers the tab
switch, independent playback state between tabs, and temp-file
lifecycle across rebuilding a preview and closing the window; also
driven live with real xdotool clicks (mark both events, build preview,
confirm auto-switch to the Preview tab with the clip already playing).
2026-07-26 03:17:01 +02:00
ThePendulum eb7c1aa500 Add sync preview, restore Detect All Claps button, reorder markers panel
- Preview Sync button builds a short (8s) lossless clip around the
  marked events with the current offset applied, and plays it in a
  small popup PreviewDialog (its own independent PlayerWidget/mpv
  instance) -- lets you check the fix sounds/looks right before
  committing to a full export. ExportWorker gained optional
  clip_start/clip_duration so both the real export and this preview
  share the same underlying export_synced() call.
- Detect All Claps is back as a button in the Sync markers panel
  (alongside Mark Audio/Video Event), in addition to staying in the
  Tools menu -- both widgets' enabled state are kept in sync.
- Sync markers panel now shows Mark Audio Event before Mark Video
  Event.

The clip-mode branch of ffmpeg_export.py (the -ss/-t-on-both-inputs
path ffdelay's own notes flag as easy to get subtly wrong) hadn't been
covered by the existing roundtrip test, which only exercised full-file
export -- added a clip-mode case there, independently verified via the
same scene-change/silence-detection technique as the other cases:
29.4ms drift after the fix, well under one frame.

All of this was built and verified entirely on a virtual Xvfb display
(DISPLAY=:99) per the user's request to stop interrupting their real
desktop -- real mouse clicks and a real (non-monkeypatched) preview
dialog were exercised there, screenshotted, and confirmed correct,
without ever touching the real screen.
2026-07-26 03:01:06 +02:00
ThePendulum 6f76b3f15a Arrow-key skip preserves play/pause state instead of always pausing
_seek_relative() was unconditionally calling player.pause() before
seeking. mpv's seek command doesn't itself change pause state, so
simply not forcing it is enough: paused stays paused, playing keeps
playing through the skip. (Frame-step still always pauses, correctly
-- that's for precisely inspecting a single frame, a different
use case from a coarse 5s nudge.)

Verified by calling _seek_relative() directly rather than simulating
the QShortcut keypress: a debug script isolated that PlayerWidget.seek()
itself works fine while playing, and QTest.keyClick against this
shortcut turned out to be unreliable in this headless setup even with
Qt's active-window state forced -- consistent with the flakiness
already noted in smoke_test_arrow_seek.py. The real key binding itself
was already confirmed with live xdotool keypresses in an earlier
session; this only needed to test the new state-preservation logic.
2026-07-26 02:44:37 +02:00
ThePendulum 105dd5be88 LosslessCut-style layout: File menu, timeline above transport, centered controls, right-click play/pause
- Load File moved from a button into a new File menu.
- Timeline now sits directly under the video; the transport row
  (frame-step/play/frame-step, centered as a group; mute pinned right)
  moved below it, matching LosslessCut's layout instead of the previous
  buttons-then-timeline order.
- Right-click on the video toggles play/pause.

The right-click implementation took two tries, worth recording: mpv
embeds via wid as its own native child window, which receives raw X11
input directly -- a real mouse click never reaches this QWidget's
mousePressEvent at all, even though a unit-style test that called the
handler directly passed and looked fine. Caught this by testing with
an actual xdotool click against a live running window instead of
trusting the synthetic test, which is what this project's smoke tests
have been doing throughout and what caught the bug here too. Fixed by
binding MBTN_RIGHT through mpv's own on_key_press input API instead,
which does receive the real clicks -- verified the same way, with a
real click on a live window, position/button-text actually changing.

Also fixed a genuinely flaky smoke test in the process: QShortcut's
active-window check races with real desktop window-manager focus,
especially with another instance of this same app already open
elsewhere (happened during this session). Switched that test to set
Qt's active-window state directly rather than relying on WM-level
activation, and independently confirmed the real key bindings with
live xdotool keypresses (both directions, plus end-of-file clamping).
2026-07-26 02:19:54 +02:00
ThePendulum a9b9ef1ce9 Arrow-key seek, log hidden by default, loading status message
- Left/Right arrow keys skip -/+5s (clamped to file bounds), pausing
  first. QShortcut auto-repeats on its own while held, so no extra
  timer/logic was needed for 'hold to keep skipping'.
- ffmpeg log panel starts hidden (view_menu default flipped to
  unchecked) rather than visible by default.
- _load() now shows 'Loading <name>...' in the status bar and forces
  one repaint before the blocking mpv load call, then 'Loaded <name>'
  afterward -- player.load() itself stays synchronous, this just makes
  the wait visible instead of a silent freeze.

Also loosened an unrelated flaky assertion in smoke_test_ui.py: hr-seek
was observed landing within ~2 frames of the nominal target across
runs (decode-order/B-frame timing, not a regression), and the old
34ms/1-frame tolerance was tighter than that natural variance.
2026-07-26 02:10:24 +02:00
ThePendulum fdb858d88c Add double-clickable launcher inside the project folder itself
Same Exec as packaging/av-sync-marker.desktop, but placed at the repo
root so it shows up right in the file manager when browsing to
WeAreSyncing/, not just in the app menu. Nemo requires such an
in-folder .desktop file to be both executable and marked 'trusted'
(gio set ... metadata::trusted true) before it'll launch without a
security prompt -- done locally, but note that trust flag lives in
GVFS's metadata store keyed off this file's inode, not in the file
itself, so it won't survive the file being rewritten (e.g. by a fresh
git checkout) and may need to be re-set with the same gio command.
2026-07-26 02:05:04 +02:00
ThePendulum f37e8ed5ea Add .desktop launcher for file-browser/menu launching
packaging/av-sync-marker.desktop wraps run.sh so the app can be
launched from Cinnamon's menu or double-clicked/opened-with from a
file manager (Nemo etc.), including passing a selected video file as
the initial file to load via %f -- main.py already supported a CLI
file argument, this just gives the file manager a way to invoke it.

Installed to ~/.local/share/applications/ (user-level, no sudo) and
verified with `gio launch`, which is what a file manager's 'Open
With' actually invokes under the hood, not just desktop-file-validate.
2026-07-26 02:01:29 +02:00
ThePendulum 5a6c4e6e84 UI/UX polish: Tools menu, clickable timeline, live drag-seek, drag-and-drop load
- 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.
2026-07-26 01:55:32 +02:00
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
ThePendulum f6e6f79a34 Add View menu with a toggle to hide/show the ffmpeg log panel 2026-07-26 01:24:34 +02:00
ThePendulum 660275bf02 Build the full UI (build order step 4)
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.
2026-07-26 00:53:51 +02:00
ThePendulum f634dda5bb Implement sync_logic.py and ffmpeg_export.py (build order steps 2-3)
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.
2026-07-26 00:50:57 +02:00
ThePendulum 91390f751d Validate frame-accurate seeking with python-mpv (build order step 1)
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).
2026-07-26 00:37:42 +02:00
ThePendulum 7584970498 Add synthetic ground-truth test clips (in-sync + audio+350ms) 2026-07-25 23:11:38 +02:00
ThePendulum e5abb1293b Add project brief 2026-07-25 23:09:55 +02:00