Skip to content

Smooth out waveform/video playback cursor (lag, stutter, seek delay)#11627

Merged
niksedk merged 6 commits into
SubtitleEdit:mainfrom
shanytc:fix/playback-cursor-delay
Jun 15, 2026
Merged

Smooth out waveform/video playback cursor (lag, stutter, seek delay)#11627
niksedk merged 6 commits into
SubtitleEdit:mainfrom
shanytc:fix/playback-cursor-delay

Conversation

@shanytc

@shanytc shanytc commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

Problem

The waveform/video playback cursor (the vertical position line) had several issues during playback:

  • Lagged ~100–200 ms behind playback.
  • Froze and then jumped forward when resuming after a seek.
  • Stuttered during steady playback (worse over loud audio).
  • Clicking the waveform to seek took ~100–200 ms before the line moved to the new spot.
  • Pressing play after placing the line caused the cursor to "rush" forward, then settle.
  • In "center waveform on current position" mode the waveform scrolled in coarse 20 fps steps.

Changes

Interpolated playhead. The cursor is advanced on a high-resolution wall clock at the current playback speed and reconciled with mpv's real position (snap on a large discontinuity, gently correct small drift, cap a single tick's advance). This hides mpv's chunky time-pos updates and its stall-then-jump when resuming after a paused seek.

Dedicated cursor timer at the right priority. The cursor is driven by its own ~60 fps DispatcherTimer. The mpv video control posts frame requests at DispatcherPriority.Render; a default (Background) timer was starved by them for 100–800 ms during decode bursts (the stutter), and even matching Render left a play-start rush, so the timer runs at Normal (above Render). The heavy per-tick work (subtitle buffer, centering, selection) stays on the existing 50 ms timer.

Rendering caches. The waveform geometry and per-paragraph shaped text are cached and replayed, so a cursor-only move no longer re-runs the per-pixel waveform loop or re-shapes FormattedText every frame (the latter caused GC pauses). Caches are keyed by view state and cleared in ResetCache().

Instant waveform seek. A waveform click/drag pins the cursor to the clicked position immediately and holds it until mpv's reported position arrives, instead of lagging on the old spot while mpv applies the async pause+seek.

Smooth center-mode scrolling. In center mode the waveform scroll is driven by the 60 fps cursor timer in lockstep with the cursor instead of the 20 fps heavy timer.

Notes

  • All changes are in MainViewModel (timers/interpolation) and AudioVisualizer (render caches); no public API/behavior changes outside the waveform.
  • The diagnosis was data-driven: temporary instrumentation confirmed the stutter was a DispatcherPriority inversion (mpv render posts starving the cursor timer), not GC or our rendering; that instrumentation has been removed.

shanytc added 4 commits June 15, 2026 00:58
The playhead cursor lagged ~100-200 ms behind playback, froze and jumped
when resuming after a seek, and stuttered during playback. Fixes:

- Interpolate the playhead on a high-resolution wall clock at the current
  playback speed, reconciling with mpv's real position (snap on large
  discontinuities, gently correct small drift). This hides mpv's chunky
  time-pos updates and its ~200 ms stall-then-jump when resuming after a
  paused seek.
- Drive the cursor from a dedicated ~60 fps DispatcherTimer created at
  DispatcherPriority.Render. The mpv video control posts frame requests at
  Render priority; a default (Background) timer was starved by them for
  100-800 ms during decode bursts, which was the visible stutter. The heavy
  per-tick work (subtitle buffer, centering, selection) stays on the 50 ms
  timer.
- Cache the waveform geometry and the per-paragraph shaped text so a
  cursor-only move replays cheap draw calls instead of re-running the
  per-pixel waveform loop and re-shaping FormattedText every frame (the
  latter caused GC pauses). Caches are keyed by view state and cleared in
  ResetCache().
- When the user seeks via the waveform, pin the cursor to the clicked
  position immediately and hold it until mpv's reported position arrives
  (or a short timeout). mpv applies pause+seek asynchronously, so without
  this the cursor lagged ~100-200 ms on the old spot before snapping.
- Cap a single interpolation tick's forward advance. When the cursor timer
  is starved by mpv's decode burst right after pressing play, the next tick
  no longer advances by the whole missed span (a visible forward "rush");
  it glides one small step and lets drift correction reconcile.
Matching DispatcherPriority.Render let a burst of mpv video-frame posts
(same priority) still delay the cursor timer via FIFO ordering right after
pressing play, so the cursor lagged and then raced to catch up. Running the
timer at Normal (above Render) keeps it firing on time through the decode
burst, so the line glides smoothly from the start. Per-tick work is ~0.2 ms,
so it doesn't disturb video rendering.
In "center waveform on current video position while playing" mode the
waveform scroll position was updated only by the 50 ms heavy timer, so it
jumped in 20 fps steps while the cursor moved at 60 fps. Drive the scroll
from the 60 fps cursor timer in lockstep with the cursor so it glides
smoothly; the heavy timer now only refreshes the paragraph list in this mode.
@niksedk

niksedk commented Jun 15, 2026

Copy link
Copy Markdown
Member

Two things from reviewing this branch:

1. Playhead estimate isn't maintained in layouts without a waveform (functional regression)

In the cursor timer, UpdatePlayheadEstimate is only called inside if (av != null):

var av = AudioVisualizer;
if (av != null)
{
    var est = UpdatePlayheadEstimate(vp);   // only runs when the waveform exists
    av.CurrentVideoPositionSeconds = est;
    ...
}

_playheadEstimateSeconds is now the source of truth for the 50 ms _positionTimer:

var mediaPlayerSeconds = _playheadEstimateSeconds;

But the position timer runs whenever vp != null && _videoFileName is set — it does not require av. Several layouts have a video player and no waveform (e.g. MakeLayout8 in InitLayout.cs — video + list). In those, AudioVisualizer is null, so the estimate is never advanced (and PinPlayheadTo only fires from waveform events), leaving _playheadEstimateSeconds stuck at 0. Everything downstream that reads mediaPlayerSeconds then breaks during playback: SelectCurrentSubtitleWhilePlaying, play-selection end detection, and the auto-scroll branches. The old code read vp.Position directly here, so it worked regardless of the waveform being present.

Suggested fix — always maintain the estimate; only the visual updates need av:

var est = UpdatePlayheadEstimate(vp);
var av = AudioVisualizer;
if (av != null)
{
    av.CurrentVideoPositionSeconds = est;
    if (WaveformCenter && vp.IsPlaying && av.WavePeaks != null)
    {
        var halfSeconds = (av.EndPositionSeconds - av.StartPositionSeconds) / 2.0;
        av.StartPositionSeconds = Math.Max(0, est - halfSeconds);
    }
}

2. Stale comment

AudioVisualizer.cs:2217 still says // See DrawWaveFormFancy: — that method was renamed to BuildWaveFormFancy in this PR.

Otherwise this looks great — nice diagnosis-driven comments, and the waveform geometry/text caching plus the cache-key invalidation all check out. SMPTE handling stays consistent too (the manual 1000/1001 in UpdatePlayheadEstimate reproduces what vp.Position previously carried).

Addresses PR review:

- Always call UpdatePlayheadEstimate in the cursor timer, not only when a
  waveform exists. The 50 ms _positionTimer reads _playheadEstimateSeconds as
  its source of truth (SelectCurrentSubtitleWhilePlaying, play-selection end
  detection, auto-scroll), and some layouts have a video player but no
  waveform (e.g. MakeLayout8), where AudioVisualizer is null. Previously the
  estimate stayed at 0 in those layouts and broke playback-driven logic. Only
  the visual updates (cursor position, center-mode scroll) need the waveform.
- Update a stale comment referencing DrawWaveFormFancy (renamed to
  BuildWaveFormFancy in this PR).
@shanytc

shanytc commented Jun 15, 2026

Copy link
Copy Markdown
Contributor Author

Two things from reviewing this branch:

1. Playhead estimate isn't maintained in layouts without a waveform (functional regression)

In the cursor timer, UpdatePlayheadEstimate is only called inside if (av != null):

var av = AudioVisualizer;
if (av != null)
{
    var est = UpdatePlayheadEstimate(vp);   // only runs when the waveform exists
    av.CurrentVideoPositionSeconds = est;
    ...
}

_playheadEstimateSeconds is now the source of truth for the 50 ms _positionTimer:

var mediaPlayerSeconds = _playheadEstimateSeconds;

But the position timer runs whenever vp != null && _videoFileName is set — it does not require av. Several layouts have a video player and no waveform (e.g. MakeLayout8 in InitLayout.cs — video + list). In those, AudioVisualizer is null, so the estimate is never advanced (and PinPlayheadTo only fires from waveform events), leaving _playheadEstimateSeconds stuck at 0. Everything downstream that reads mediaPlayerSeconds then breaks during playback: SelectCurrentSubtitleWhilePlaying, play-selection end detection, and the auto-scroll branches. The old code read vp.Position directly here, so it worked regardless of the waveform being present.

Suggested fix — always maintain the estimate; only the visual updates need av:

var est = UpdatePlayheadEstimate(vp);
var av = AudioVisualizer;
if (av != null)
{
    av.CurrentVideoPositionSeconds = est;
    if (WaveformCenter && vp.IsPlaying && av.WavePeaks != null)
    {
        var halfSeconds = (av.EndPositionSeconds - av.StartPositionSeconds) / 2.0;
        av.StartPositionSeconds = Math.Max(0, est - halfSeconds);
    }
}

2. Stale comment

AudioVisualizer.cs:2217 still says // See DrawWaveFormFancy: — that method was renamed to BuildWaveFormFancy in this PR.

Otherwise this looks great — nice diagnosis-driven comments, and the waveform geometry/text caching plus the cache-key invalidation all check out. SMPTE handling stays consistent too (the manual 1000/1001 in UpdatePlayheadEstimate reproduces what vp.Position previously carried).

Thanks for the review! Both addressed in latest commit:

  1. UpdatePlayheadEstimate() is now always called in the cursor timer, only the visual updates are gated on the waveform existing, so video-only layouts keep _playheadEstimateSeconds current.
  2. Fixed the DrawWaveFormFancy comment.
  3. Also latest changes holds the cursor in place while mpv's clock is frozen after a seek (instead of racing ahead at 1×) and makes the drift correction forward-only, eliminating the "runs fast then slow" play-start behavior.

Per-tick logging showed that after a paused seek mpv's time-pos (and the
video) freezes for ~400 ms while it re-primes, then resumes at 1x from the
same spot. The wall-clock interpolation extrapolated straight through that
freeze, racing the cursor ~0.4 s ahead of the still-frozen frame, then a
sub-1x crawl pulled it back to resync — the "runs fast then slow" the user
saw.

- Track when mpv's reported position last changed; if it has been frozen
  longer than PlayheadFreezeHoldSeconds (120 ms), hold the cursor instead of
  extrapolating. mpv resumes from where the cursor is held, so it's seamless.
- Make the drift correction forward-only so it can close a lag but never pull
  the cursor backward (the sub-1x "slow").
@niksedk niksedk merged commit 6a57c02 into SubtitleEdit:main Jun 15, 2026
2 checks passed
@niksedk

niksedk commented Jun 15, 2026

Copy link
Copy Markdown
Member

Also seems to work for vlc :)

@shanytc

shanytc commented Jun 15, 2026

Copy link
Copy Markdown
Contributor Author

Also seems to work for vlc :)

thanks for merging :) i'd be happy to help here and there! i love subtitleedit! been working with it for years.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants