feat: J/K/L playback speed shuttle and V for select mode - #2374
YoavLevavi wants to merge 2 commits into
Conversation
L starts playback and steps it up through 1.5x/2x/3x/4x, J steps it down to 0.5x, and K stops and resets to 1x; a badge shows the current speed. Space and the play button always play at 1x. V returns the timeline to select mode (S still toggles split). The playback clock runs at the chosen rate and the audio output consumes source audio at the same rate through a streaming WSOLA time-stretcher, so shuttling keeps the original pitch. Audio the stretcher has pulled ahead is counted in the video-sync latency. Non-f32 output devices fall back to frame decimation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| if (playbackIntent()) { | ||
| await commands.stopPlayback(); | ||
| setEditorState("playing", false); | ||
| } | ||
| setEditorState("playbackTime", time); | ||
| await commands.seekTo(frame); | ||
| await commands.startPlayback(FPS, previewResolutionBase()); |
There was a problem hiding this comment.
If the user presses L and then K quickly, the two handlers can overlap. K can stop the old session while L is still seeking, after which L starts a new session. Playback then continues despite K being the last key pressed, and K's rate reset does not change the running session.
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/routes/editor/Player.tsx
Line: 287-293
Comment:
**Stop can miss playback**
If the user presses L and then K quickly, the two handlers can overlap. K can stop the old session while L is still seeking, after which L starts a new session. Playback then continues despite K being the last key pressed, and K's rate reset does not change the running session.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Fixed in 82c7ea3. Shuttle (J/K/L) and play/pause handlers now run through a single promise queue, so K always runs after an in-flight L finishes starting its session, then stops it and resets the rate.
| if (pending) { | ||
| await pending; | ||
| return; |
There was a problem hiding this comment.
Preparing handoff drops shuttle speed
When the user presses J or L during preparing playback, this branch returns before applying the requested rate. Adopted playback runs at 1×, so the first shuttle press has no speed effect; the user must press the key again after the handoff.
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/routes/editor/Player.tsx
Line: 279-281
Comment:
**Preparing handoff drops shuttle speed**
When the user presses J or L during preparing playback, this branch returns before applying the requested rate. Adopted playback runs at 1×, so the first shuttle press has no speed effect; the user must press the key again after the handoff.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Fixed in 82c7ea3. After awaiting the preparing handoff, shuttle only returns early when the requested rate is 1×; otherwise it restarts the adopted session at the requested rate, so the first J/L press takes effect.
| let mut candidate = lowest; | ||
| while candidate <= highest { | ||
| let mut score = 0.0; | ||
| let mut i = 0; | ||
| while i < HOP_FRAMES { | ||
| score += self.mono(candidate + i) * self.mono(natural + i); |
There was a problem hiding this comment.
Audio callback search is expensive
Each hop searches roughly 385 positions and compares 384 sample positions per candidate. On stereo output, that is roughly 590,000 sample lookups per hop, while filling the output block can also grow buffers. Doing this on the audio callback thread risks missed deadlines and audible glitches on devices with small buffers.
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/editor/src/time_stretch.rs
Line: 110-115
Comment:
**Audio callback search is expensive**
Each hop searches roughly 385 positions and compares 384 sample positions per candidate. On stereo output, that is roughly 590,000 sample lookups per hop, while filling the output block can also grow buffers. Doing this on the audio callback thread risks missed deadlines and audible glitches on devices with small buffers.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Fixed in 82c7ea3. The alignment search is now coarse-to-fine: a pass with an 8-frame stride over candidates and samples, then a ±7-frame refinement. That cuts the work per hop from about 590k sample lookups to about 30k on stereo. The accumulator's drain and resize stays within its existing capacity, so the hop doesn't allocate.
| fn mono(&self, absolute: usize) -> f32 { | ||
| (0..self.channels).map(|c| self.frame(absolute, c)).sum() | ||
| } |
There was a problem hiding this comment.
For valid opposite-phase stereo audio, summing the channels makes every alignment comparison score zero. The search then selects the first candidate rather than a matching overlap, which can introduce avoidable artifacts during shuttle playback.
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/editor/src/time_stretch.rs
Line: 97-99
Comment:
**Stereo channels can cancel**
For valid opposite-phase stereo audio, summing the channels makes every alignment comparison score zero. The search then selects the first candidate rather than a matching overlap, which can introduce avoidable artifacts during shuttle playback.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Fixed in 82c7ea3. Correlation now sums per-channel products instead of correlating a mono downmix, so opposite-phase stereo still aligns. Added aligns_opposite_phase_stereo as a regression test; it fails on the previous implementation.
- serialize shuttle and play/pause handlers so a quick K after L always stops - apply the requested shuttle rate after a preparing-playback handoff - search WSOLA alignment coarse-to-fine to keep the audio callback cheap - correlate each channel separately so opposite-phase stereo still aligns Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What
Implementation
Playbackgains aplayback_rate. The frame clock (frame interval, overshoot and drift math) runs at that rate; the rate is set via a newset_playback_ratecommand and applied by restarting playback at the playhead.crates/editor/src/time_stretch.rs), so shuttling keeps the original pitch instead of sounding sped up. Audio the stretcher has pulled ahead is counted in the video-sync latency, and a resync resets it. Other sample formats fall back to frame decimation.Testing
time_stretchunit test: a 440 Hz tone stretched at 2x stays at 440 Hz while consuming ~2x the source.cargo clippy -p cap-editor -p cap-desktop --all-targets -D warningsis clean;cap-editortests pass.tsc --noEmitare clean.🤖 Generated with Claude Code
The PR should not merge until shuttle commands are ordered and speed requests during preparing handoffs are handled.
Findings
Fix with agent prompt
Summary
This PR adds J/K/L shuttle controls, a V shortcut for select mode, rate-aware video and audio playback, and f32 WSOLA stretching.
Reviews (1) · Last reviewed commit: "feat: J/K/L playback speed shuttle and V..."