Skip to content

Commit 5dba5c1

Browse files
committed
feat: implement playback speed display and timescale adjustments in audio playback
1 parent d210911 commit 5dba5c1

File tree

4 files changed

+189
-38
lines changed

4 files changed

+189
-38
lines changed

apps/desktop/src/routes/editor/Player.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,12 @@ export function Player() {
136136
>
137137
Crop
138138
</EditorButton>
139+
<Show when={currentSpeed()}>
140+
<div class="flex items-center gap-1 px-3 py-1 bg-blue-500/20 rounded-lg text-gray-600 font-medium">
141+
<IconLucideClock class="size-4 mr-1" />
142+
{currentSpeed()}
143+
</div>
144+
</Show>
139145
</div>
140146
<PreviewCanvas />
141147
<div class="flex z-10 overflow-hidden flex-row gap-3 justify-between items-center p-5">

apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,25 @@ export function ClipTrack(props: Pick<ComponentProps<"div">, "ref">) {
4242
.slice(0, i())
4343
.reduce((t, s) => t + (s.end - s.start) / s.timescale, 0);
4444

45+
const getSpeedClass = () => {
46+
if (segment.timescale === 1) return "";
47+
if (segment.timescale > 1)
48+
return "bg-stripes-blue border-l-4 border-l-blue-500";
49+
return "bg-stripes-green border-l-4 border-l-green-500";
50+
};
51+
52+
const getSpeedText = () => {
53+
if (segment.timescale === 1) return null;
54+
return (1 / segment.timescale).toFixed(2) + "x";
55+
};
56+
4557
return (
4658
<SegmentRoot
4759
class={cx(
4860
"overflow-hidden border border-transparent transition-colors duration-300 group",
4961
"hover:border-gray-500",
5062
"bg-gradient-to-r timeline-gradient-border from-[#2675DB] via-[#4FA0FF] to-[#2675DB] shadow-[inset_0_5px_10px_5px_rgba(255,255,255,0.2)]",
51-
segment.timescale !== 1 && "bg-stripes-blue"
63+
getSpeedClass()
5264
)}
5365
innerClass="ring-blue-300"
5466
segment={{
@@ -164,10 +176,9 @@ export function ClipTrack(props: Pick<ComponentProps<"div">, "ref">) {
164176
<IconLucideClock class="size-3.5" />{" "}
165177
{(segment.end - segment.start).toFixed(1)}s
166178
</div>
167-
<Show when={segment.timescale !== 1}>
168-
<div class="flex gap-1 items-center text-gray-50 dark:text-gray-500 text-md bg-blue-500/20 px-2 py-0.5 rounded-full mt-1">
169-
<IconLucideClock class="size-3" />{" "}
170-
{(1 / segment.timescale).toFixed(2)}x
179+
<Show when={getSpeedText()}>
180+
<div class="flex gap-1 items-center text-gray-50 dark:text-gray-500 text-md bg-blue-500/30 px-2 py-1 rounded-full mt-1 font-medium shadow-sm">
181+
<IconLucideClock class="size-3" /> {getSpeedText()}
171182
</div>
172183
</Show>
173184
</div>

crates/editor/src/playback.rs

Lines changed: 102 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use std::{sync::Arc, time::Duration};
33
use cap_media::data::{AudioInfo, FromSampleBytes};
44
use cap_media::feeds::{AudioPlaybackBuffer, AudioSegment, AudioSegmentTrack};
55
use cap_media::MediaError;
6-
use cap_project::{AudioConfiguration, ProjectConfiguration, XY};
6+
use cap_project::{AudioConfiguration, ProjectConfiguration, TimelineSegment, XY};
77
use cap_rendering::{ProjectUniforms, RenderVideoConstants};
88
use cpal::{
99
traits::{DeviceTrait, HostTrait, StreamTrait},
@@ -68,46 +68,117 @@ impl Playback {
6868
}
6969
.spawn();
7070

71+
let mut current_segment_index: Option<usize> = None;
72+
let mut segment_start_time = 0.0;
73+
let mut segment_elapsed = 0.0;
74+
let mut segment_timescale = 1.0;
75+
7176
loop {
72-
let time =
73-
(self.start_frame_number as f64 / fps as f64) + start.elapsed().as_secs_f64();
74-
let frame_number = (time * fps as f64).floor() as u32;
77+
let elapsed = start.elapsed().as_secs_f64();
78+
let playhead_time = (self.start_frame_number as f64 / fps as f64) + elapsed;
79+
80+
let project = self.project.borrow().clone();
81+
let mut time_in_timeline = 0.0;
82+
let mut segment_found = false;
83+
84+
if let Some(timeline) = &project.timeline {
85+
for (i, segment) in timeline.segments.iter().enumerate() {
86+
let segment_duration = (segment.end - segment.start) / segment.timescale;
87+
88+
if playhead_time >= time_in_timeline
89+
&& playhead_time < time_in_timeline + segment_duration
90+
{
91+
if current_segment_index != Some(i) {
92+
current_segment_index = Some(i);
93+
segment_start_time = time_in_timeline;
94+
segment_elapsed = playhead_time - time_in_timeline;
95+
segment_timescale = segment.timescale;
96+
} else {
97+
segment_elapsed = playhead_time - segment_start_time;
98+
}
99+
segment_found = true;
100+
break;
101+
}
102+
103+
time_in_timeline += segment_duration;
104+
}
105+
}
106+
107+
if !segment_found {
108+
if playhead_time >= duration {
109+
break;
110+
}
111+
current_segment_index = Some(0);
112+
segment_start_time = 0.0;
113+
segment_elapsed = playhead_time;
114+
segment_timescale = 1.0;
115+
}
116+
117+
let frame_number = (playhead_time * fps as f64).floor() as u32;
75118

76119
if frame_number as f64 >= fps as f64 * duration {
77120
break;
78121
};
79122

80-
let project = self.project.borrow().clone();
123+
let segment_time = if let Some(segment_idx) = current_segment_index {
124+
if let Some(timeline) = &project.timeline {
125+
if segment_idx < timeline.segments.len() {
126+
let segment = &timeline.segments[segment_idx];
127+
segment.start + segment_elapsed * segment_timescale
128+
} else {
129+
elapsed
130+
}
131+
} else {
132+
elapsed
133+
}
134+
} else {
135+
elapsed
136+
};
81137

82-
if let Some((segment_time, segment_i)) = project.get_segment_time(time) {
83-
let segment = &self.segments[segment_i as usize];
84-
85-
let data = tokio::select! {
86-
_ = stop_rx.changed() => { break; },
87-
data = segment.decoders.get_frames(segment_time as f32, !project.camera.hide) => { data }
88-
};
89-
90-
if let Some(segment_frames) = data {
91-
let uniforms = ProjectUniforms::new(
92-
&self.render_constants,
93-
&project,
94-
frame_number,
95-
fps,
96-
resolution_base,
97-
);
98-
99-
self.renderer
100-
.render_frame(segment_frames, uniforms, segment.cursor.clone())
101-
.await;
138+
if let Some(segment_i) = current_segment_index {
139+
if segment_i < self.segments.len() {
140+
let segment = &self.segments[segment_i];
141+
142+
let data = tokio::select! {
143+
_ = stop_rx.changed() => { break; },
144+
data = segment.decoders.get_frames(segment_time as f32, !project.camera.hide) => { data }
145+
};
146+
147+
if let Some(segment_frames) = data {
148+
let uniforms = ProjectUniforms::new(
149+
&self.render_constants,
150+
&project,
151+
frame_number,
152+
fps,
153+
resolution_base,
154+
);
155+
156+
self.renderer
157+
.render_frame(segment_frames, uniforms, segment.cursor.clone())
158+
.await;
159+
}
102160
}
103161
}
104162

105-
tokio::time::sleep_until(
106-
start
107-
+ (frame_number - self.start_frame_number)
108-
* Duration::from_secs_f32(1.0 / fps as f32),
109-
)
110-
.await;
163+
let next_frame = (frame_number + 1) as f64 / fps as f64;
164+
let time_to_wait = if let Some(segment_idx) = current_segment_index {
165+
if let Some(timeline) = &project.timeline {
166+
if segment_idx < timeline.segments.len() {
167+
let segment = &timeline.segments[segment_idx];
168+
Duration::from_secs_f64(
169+
(next_frame - playhead_time) * segment.timescale,
170+
)
171+
} else {
172+
Duration::from_secs_f64(next_frame - playhead_time)
173+
}
174+
} else {
175+
Duration::from_secs_f64(next_frame - playhead_time)
176+
}
177+
} else {
178+
Duration::from_secs_f64(next_frame - playhead_time)
179+
};
180+
181+
tokio::time::sleep(time_to_wait).await;
111182

112183
event_tx.send(PlaybackEvent::Frame(frame_number)).ok();
113184
}

crates/media/src/feeds/audio.rs

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,7 @@ pub struct AudioPlaybackBuffer<T: FromSampleBytes> {
325325
frame_buffer: AudioRenderer,
326326
resampler: AudioResampler,
327327
resampled_buffer: HeapRb<T>,
328+
current_timescale: f64,
328329
}
329330

330331
impl<T: FromSampleBytes> AudioPlaybackBuffer<T> {
@@ -349,11 +350,45 @@ impl<T: FromSampleBytes> AudioPlaybackBuffer<T> {
349350
frame_buffer,
350351
resampler,
351352
resampled_buffer,
353+
current_timescale: 1.0,
352354
}
353355
}
354356

355357
pub fn set_playhead(&mut self, playhead: f64, project: &ProjectConfiguration) {
356-
self.resampler.reset();
358+
let current_timescale = if let Some(timeline) = &project.timeline {
359+
let mut accum_duration = 0.0;
360+
361+
for segment in timeline.segments.iter() {
362+
let segment_duration = segment.duration();
363+
if playhead < accum_duration + segment_duration {
364+
return self.set_playhead_with_timescale(playhead, project, segment.timescale);
365+
}
366+
367+
accum_duration += segment_duration;
368+
}
369+
370+
1.0
371+
} else {
372+
1.0
373+
};
374+
375+
self.set_playhead_with_timescale(playhead, project, current_timescale);
376+
}
377+
378+
fn set_playhead_with_timescale(
379+
&mut self,
380+
playhead: f64,
381+
project: &ProjectConfiguration,
382+
timescale: f64,
383+
) {
384+
if self.current_timescale != timescale {
385+
self.resampler.reset();
386+
self.resampler.set_timescale(timescale).unwrap_or_else(|e| {
387+
tracing::error!("Failed to set timescale: {}", e);
388+
});
389+
self.current_timescale = timescale;
390+
}
391+
357392
self.resampled_buffer.clear();
358393
self.frame_buffer.set_playhead(playhead, project);
359394
}
@@ -400,6 +435,7 @@ pub struct AudioResampler {
400435
pub output_frame: FFAudio,
401436
delay: Option<resampling::Delay>,
402437
output: AudioInfo,
438+
timescale: f64,
403439
}
404440

405441
impl AudioResampler {
@@ -422,11 +458,38 @@ impl AudioResampler {
422458
context,
423459
output_frame: FFAudio::empty(),
424460
delay: None,
461+
timescale: 1.0,
425462
})
426463
}
427464

428465
pub fn reset(&mut self) {
429-
*self = Self::new(self.output).unwrap();
466+
*self = Self::new(self.output.clone()).unwrap();
467+
}
468+
469+
pub fn set_timescale(&mut self, timescale: f64) -> Result<(), MediaError> {
470+
if self.timescale == timescale {
471+
return Ok(());
472+
}
473+
let adjusted_rate = (AudioData::SAMPLE_RATE as f64 * timescale).round() as u32;
474+
475+
self.context = ffmpeg::software::resampler(
476+
(
477+
AudioData::SAMPLE_FORMAT,
478+
ChannelLayout::STEREO,
479+
adjusted_rate,
480+
),
481+
(
482+
self.output.sample_format,
483+
self.output.channel_layout(),
484+
self.output.sample_rate,
485+
),
486+
)?;
487+
488+
self.timescale = timescale;
489+
self.delay = None;
490+
self.output_frame = FFAudio::empty();
491+
492+
Ok(())
430493
}
431494

432495
fn current_frame_data(&self) -> &[u8] {

0 commit comments

Comments
 (0)