Skip to content

feat: J/K/L playback speed shuttle and V for select mode - #2374

Open
YoavLevavi wants to merge 2 commits into
CapSoftware:mainfrom
YoavLevavi:feat/playback-shuttle
Open

YoavLevavi wants to merge 2 commits into
CapSoftware:mainfrom
YoavLevavi:feat/playback-shuttle

Conversation

@YoavLevavi

@YoavLevavi YoavLevavi commented Sep 29, 2026 •

Copy link
Copy Markdown

What

  • J / K / L playback shuttle:
    • L starts playback and steps it up through 1.5x → 2x → 3x → 4x.
    • J steps it down, to as low as 0.5x.
    • K stops and resets to 1x.
    • A badge next to the transport shows the current speed.
    • Space and the play button always play at 1x.
  • V returns the timeline to select mode (S still toggles split), matching common NLE shortcuts.

Implementation

  • Playback gains a playback_rate. The frame clock (frame interval, overshoot and drift math) runs at that rate; the rate is set via a new set_playback_rate command and applied by restarting playback at the playhead.
  • The audio output consumes source audio at the same rate. On f32 output devices (what macOS uses) it runs through a small streaming WSOLA time-stretcher (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.
  • Adopted (preparing-editor) playback always runs at 1x.

Testing

  • time_stretch unit 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 warnings is clean; cap-editor tests pass.
  • Desktop Vitest suite passes; Biome and tsc --noEmit are clean.

🤖 Generated with Claude Code

RetriggerConfidence Score: 3/5

The PR should not merge until shuttle commands are ordered and speed requests during preparing handoffs are handled.

Findings

  1. P1 Stop can miss playback ▶
  2. P1 Preparing handoff drops shuttle speed ▶
  3. P2 Audio callback search is expensive ▶
  4. P2 Stereo channels can cancel ▶
Fix with agent prompt
### Issue 1
apps/desktop/src/routes/editor/Player.tsx:287-293
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.

### Issue 2
apps/desktop/src/routes/editor/Player.tsx:279-281
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.

### Issue 3
crates/editor/src/time_stretch.rs:110-115
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.

### Issue 4
crates/editor/src/time_stretch.rs:97-99
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.

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.

  • The shuttle controls need ordered command execution and must retain speed requests made during a preparing handoff.
  • The new audio callback search has a deadline risk, and stereo channel cancellation undermines its alignment search.

Reviews (1) · Last reviewed commit: "feat: J/K/L playback speed shuttle and V..."

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>
Comment on lines +287 to +293
if (playbackIntent()) {
await commands.stopPlayback();
setEditorState("playing", false);
}
setEditorState("playbackTime", time);
await commands.seekTo(frame);
await commands.startPlayback(FPS, previewResolutionBase());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +279 to +281
if (pending) {
await pending;
return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread crates/editor/src/time_stretch.rs Outdated
Comment on lines +110 to +115
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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread crates/editor/src/time_stretch.rs Outdated
Comment on lines +97 to +99
fn mono(&self, absolute: usize) -> f32 {
(0..self.channels).map(|c| self.frame(absolute, c)).sum()
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

This branch has not been deployed

No deployments
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.

1 participant