Skip to content

Add playback speed multiplier - #23

Closed
emlcpfx wants to merge 1 commit into
D-Mad:mainfrom
emlcpfx:pr11-playback-speed
Closed

Add playback speed multiplier#23
emlcpfx wants to merge 1 commit into
D-Mad:mainfrom
emlcpfx:pr11-playback-speed

Conversation

@emlcpfx

@emlcpfx emlcpfx commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Adds a playback speed multiplier: Playback > Speed, a combobox on the timeline toolbar, and Ctrl+[ / Ctrl+] / Ctrl+\ to step down, up, and reset. Presets run 0.25x to 4x.

Why the two players need different treatment

The interesting part of this one is that FrameDeck's two players keep time in completely different ways, so a single "multiply by N" doesn't apply to both. playback/speed.py holds the arithmetic so it can be tested without a player at all.

SequencePlayer is timer-driven. It fires every 1000 / fps ms and steps exactly one frame. Speed scales that interval — so every frame is still shown, just sooner or later. A running timer is re-armed immediately on a speed change rather than waiting for the next tick to notice.

MoviePlayer is clock-driven. It reads a monotonic elapsed time and presents whichever decoded frame is due. Speed scales the elapsed clock, so more (or fewer) decoded frames fall due per tick. Presentation stays timestamp-driven, so frames still appear in order and none get decoded twice.

Two things that would otherwise bite

The movie clock has to be re-anchored before the multiplier changes. set_speed banks the position already reached at the old speed and restarts the elapsed timer, so the new multiplier applies only from that point forward. Without it, the entire elapsed span gets rescaled retroactively and playback visibly jumps — switch to 2x ten seconds in and you'd leap to twenty. test_movie_set_speed_reanchors_the_clock pins this.

Audio is dropped at any speed other than 1x. The samples decode at their native rate, so submitting them against a scaled video clock drifts steadily out of sync, and playing them faster without resampling shifts the pitch. Rather than ship either of those, off-speed playback is silent — which is what a reviewer expects when they shuttle, and it keeps A/V sync honest. The status bar says so when you leave 1x, so nobody wonders where the sound went.

If you'd rather have pitch-corrected audio at speed, that's a real feature but a much bigger one (a resampler in the audio path); say the word and I'll scope it separately.

Robustness

  • An unusable fps yields no interval rather than dividing by zero — callers read 0 as "don't start the timer".
  • A junk speed (NaN, zero, negative, a string) falls back to 1x rather than stopping playback.
  • Speed is clamped to a sane range even if set programmatically.

Tests

23 new tests, 274 on the branch, green three runs in a row.

One gotcha worth flagging for anyone else writing player tests: SequencePlayer starts a decode QThread in its constructor, and only reset() shuts it down. A test that constructs one and doesn't tear it down leaves the thread running into interpreter exit and kills the process with a fail-fast exception — which looks like a flaky suite, not a leak. The fixtures here handle it.

Verified end to end through a real MainWindow: the combobox drives the window, the menu drives the combobox back, and the shortcuts step through the presets and clamp at both ends without falling off the list.

Branches off current main.

Playback > Speed, a timeline combobox, and Ctrl+[ / Ctrl+] / Ctrl+\ select
0.25x through 4x.

The two players keep time differently, so speed applies differently to each,
and playback/speed.py holds the arithmetic so it can be tested on its own:

- SequencePlayer is timer-driven: it fires every 1000/fps ms and steps one
  frame. Speed scales that interval, so every frame is still shown, just
  sooner or later. A running timer is re-armed immediately rather than
  waiting for the next tick.
- MoviePlayer is clock-driven: it reads a monotonic elapsed time and presents
  whichever decoded frame is due. Speed scales the elapsed clock, so more (or
  fewer) frames fall due per tick. Presentation stays timestamp-driven, so
  frames still appear in order and none are decoded twice.

Changing speed mid-playback re-anchors the movie clock first: it banks the
position already reached at the old speed and restarts the elapsed timer, so
the multiplier applies only from that point. Without this the whole elapsed
span is rescaled retroactively and playback jumps.

Audio is dropped at any speed other than 1x. The samples decode at their
native rate, so submitting them against a scaled video clock drifts steadily
out of sync, and playing them faster without resampling shifts the pitch.
Off-speed playback is silent, which is what a reviewer expects when they
shuttle, and it keeps A/V sync honest. The status bar says so.

An unusable fps yields no interval rather than dividing by zero, and a junk
speed falls back to 1x rather than stopping playback.

23 tests. Note SequencePlayer starts a decode QThread in its constructor and
only reset() shuts it down; the test fixtures do that, or the process dies at
exit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TexnzYbmCjjTDB8zzZuUPb
@D-Mad

D-Mad commented Jul 15, 2026

Copy link
Copy Markdown
Owner

Thank you, Eric. The authored speed feature has been preserved and rebased onto current main in #29. Review found that speed reset silently when a concrete player was replaced, and A/B could inherit mismatched speeds; #29 fixes persistence across source/playlist/Compare/session changes and adds a regression test. Closing this branch in favor of #29.

@D-Mad D-Mad closed this Jul 15, 2026
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