diff --git a/crates/engine/src/preview.rs b/crates/engine/src/preview.rs index 76d21a806..7c0ee02cd 100644 --- a/crates/engine/src/preview.rs +++ b/crates/engine/src/preview.rs @@ -159,7 +159,8 @@ pub struct PreviewPreset { pub pre_roll: f64, pub post_roll: f64, pub play_from: PlayFrom, - /// Frames per second; `None` = Auto (the comp's rate). + /// Composition frames played per second; `None` = Auto (the comp's rate). + /// An override changes preview speed, including the audio's speed and pitch. pub frame_rate: Option, /// Frames skipped between the frames played (0 = every frame). pub skip: u32, diff --git a/crates/ui-egui/src/app/playback.rs b/crates/ui-egui/src/app/playback.rs index 51dd04ee1..b68e58f27 100644 --- a/crates/ui-egui/src/app/playback.rs +++ b/crates/ui-egui/src/app/playback.rs @@ -57,12 +57,10 @@ impl EffectcraftApp { } } - /// The preview includes audio at the comp's frame rate and the comp has something audible. + /// The preview includes audio and the comp has something audible. pub(super) fn audio_plays(&self) -> bool { let (Some(pl), Some(cid)) = (self.playback.plan, self.session.active_comp_id()) else { return false }; - let Some(c) = self.session.project.comp(cid) else { return false }; - // A preview at another frame rate plays slower or faster than real time: silent. - pl.audio && (pl.fps - c.frame_rate.as_f64()).abs() <= 0.01 && effectcraft_engine::render::audio::comp_has_audio(&self.session.project, cid) + pl.audio && effectcraft_engine::render::audio::comp_has_audio(&self.session.project, cid) } /// Start audio preview from comp time `t` when [`Self::audio_plays`] and an output device @@ -76,7 +74,17 @@ impl EffectcraftApp { let Some(dev) = self.hooks.audio_device.as_ref().and_then(|f| f(&out)) else { return }; let mix = self.session.prefs.audio.preview_sample_rate; let fr = c.frame_rate; - match audio::AudioPlayback::start(dev, self.render_source(), cid, t, fr.tick_of(pl.start), fr.tick_of(pl.end + 1), pl.looping, mix) { + match audio::AudioPlayback::start_at_speed( + dev, + self.render_source(), + cid, + t, + fr.tick_of(pl.start), + fr.tick_of(pl.end + 1), + pl.looping, + mix, + pl.fps / fr.as_f64(), + ) { Ok(a) => self.audio = Some(a), Err(e) => log::warn!("audio preview: {e}"), } diff --git a/crates/ui-egui/src/app/prefetch.rs b/crates/ui-egui/src/app/prefetch.rs index 22e587643..4287ea8f6 100644 --- a/crates/ui-egui/src/app/prefetch.rs +++ b/crates/ui-egui/src/app/prefetch.rs @@ -1,4 +1,4 @@ -use super::{EffectcraftApp, FrameKey, FrameWindow, ItemId, PlaybackPass, audio, frames}; +use super::{EffectcraftApp, FrameKey, FrameWindow, ItemId, PlaybackPass, frames}; /// Seconds without a project change before paused prefetch resumes (see /// [`EffectcraftApp::editing`]). @@ -321,7 +321,7 @@ impl EffectcraftApp { return true; } let snap = |f: i64| wa + (f - wa).div_euclid(step) * step; - let target = snap(audio::frame_of_sample(a.clock(), a.rate, fr).clamp(wa, wb)); + let target = snap(fr.frame_at(a.clock_time()).clamp(wa, wb)); if !video { self.playback.audio_frame = Some(target); } else if self.frames.is_cached(&FrameKey { frame: target, ..series }) { @@ -350,7 +350,7 @@ impl EffectcraftApp { fn sound_frame(&self, fr: effectcraft_engine::time::FrameRate, wa: i64, wb: i64, step: i64) -> Option { let a = self.audio.as_ref()?; let step = step.max(1); - Some(wa + (audio::frame_of_sample(a.clock(), a.rate, fr).clamp(wa, wb) - wa).div_euclid(step) * step) + Some(wa + (fr.frame_at(a.clock_time()).clamp(wa, wb) - wa).div_euclid(step) * step) } /// The last cached frame after the current one and before `target`, in play order: what a diff --git a/crates/ui-egui/src/audio.rs b/crates/ui-egui/src/audio.rs index 960aa71e2..e0d6b4d1b 100644 --- a/crates/ui-egui/src/audio.rs +++ b/crates/ui-egui/src/audio.rs @@ -10,7 +10,7 @@ //! **A/V sync:** while audio plays, the audio clock drives playback. The clock is the number of //! sample frames the device has actually consumed (underruns do not advance it) minus the //! device's output latency, mapped through the work-area loop ([`clock_sample`]); the viewer -//! shows the comp frame at that sample ([`frame_of_sample`]), dropping video frames rather +//! shows the comp frame at that time ([`AudioPlayback::clock_time`]), dropping video frames rather //! than drifting. //! //! The feed also tracks per-channel peaks for the Audio panel's VU meters ([`Meter`]). @@ -28,6 +28,9 @@ use effectcraft_engine::time::{FrameRate, Tick}; use crate::frames::RenderSource; +mod preview_rate; +use preview_rate::PreviewRate; + /// An audio output (implemented by the native app with cpal). pub trait AudioDevice: Send { /// Device sample rate in Hz. @@ -141,7 +144,7 @@ impl AudioFeed { } } -/// What a preview plays, in comp sample indices at the device rate. +/// What a preview plays, in output sample coordinates on its playback timeline. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct PlaySpan { pub start: i64, @@ -159,15 +162,23 @@ impl PlaySpan { } } -/// The comp sample index audible now: `consumed` frames played by the device minus its +/// The output sample coordinate audible now: `consumed` frames played by the device minus its /// `latency`, from the span start, wrapped into the work area when looping (clamped at its end /// otherwise). pub fn clock_sample(span: &PlaySpan, consumed: u64, latency: u64) -> i64 { - let s = span.start + consumed.saturating_sub(latency) as i64; - if s < span.wb { - return s; + // Slow previews can span more output samples than fit in a signed difference. Keep the + // elapsed count and loop width wide until the result is back inside the span. + let s = i128::from(span.start) + i128::from(consumed.saturating_sub(latency)); + if s < i128::from(span.wb) { + return s as i64; + } + if span.looping { + let width = (i128::from(span.wb) - i128::from(span.wa)).max(1); + let wrapped = i128::from(span.wa) + (s - i128::from(span.wb)).rem_euclid(width); + wrapped.clamp(i64::MIN as i128, i64::MAX as i128) as i64 + } else { + span.wb.saturating_sub(1) } - if span.looping { span.wa + (s - span.wb).rem_euclid((span.wb - span.wa).max(1)) } else { span.wb - 1 } } /// Comp time of sample index `s` at `rate` Hz. @@ -196,12 +207,13 @@ pub struct AudioPlayback { mix: u32, pub rate: u32, pub span: PlaySpan, + timeline: PreviewRate, } /// The mixer's position and its view of the project. struct Feeder { src: RenderSource, - /// The next comp sample index to mix. + /// The next output sample index to mix (mapped to composition time by the preview rate). cursor: i64, /// Bumped when the project changes: a block mixed from an older project is dropped. generation: u64, @@ -219,7 +231,7 @@ impl AudioPlayback { /// at (resampled to the device's rate when they differ; 0 = the device's rate). #[allow(clippy::too_many_arguments)] pub fn start( - mut device: Box, + device: Box, src: RenderSource, comp: ItemId, start: Tick, @@ -227,16 +239,34 @@ impl AudioPlayback { wb: Tick, looping: bool, mix_rate: u32, + ) -> Result { + Self::start_at_speed(device, src, comp, start, wa, wb, looping, mix_rate, 1.0) + } + + /// Start at a preview speed relative to composition time. Both the sound (including pitch) + /// and its composition clock follow this speed; the physical device rate stays unchanged. + #[allow(clippy::too_many_arguments)] + pub fn start_at_speed( + mut device: Box, + src: RenderSource, + comp: ItemId, + start: Tick, + wa: Tick, + wb: Tick, + looping: bool, + mix_rate: u32, + speed: f64, ) -> Result { let rate = device.sample_rate().max(8000); let mix = if mix_rate == 0 { rate } else { mix_rate.clamp(8000, 192_000) }; - let span = PlaySpan::new(start, wa, wb, looping, rate); + let timeline = PreviewRate::new(rate, speed, start)?; + let span = timeline.span(start, wa, wb, looping); let feed = Arc::new(AudioFeed::default()); let stop = Arc::new(AtomicBool::new(false)); let state = Arc::new(Mutex::new(Feeder { src, cursor: span.start, generation: 0 })); if cfg!(target_arch = "wasm32") { // The device takes samples only in `pump`, which fills the queue first. - let mut p = AudioPlayback { feed: feed.clone(), device, stop, thread: None, state, comp, mix, rate, span }; + let mut p = AudioPlayback { feed: feed.clone(), device, stop, thread: None, state, comp, mix, rate, span, timeline }; p.device.start(feed)?; p.pump(); return Ok(p); @@ -253,7 +283,7 @@ impl AudioPlayback { let target = queue_target(rate); while !stop.load(Ordering::Relaxed) { if feed.queued_frames() < target && !feed.finished.load(Ordering::Relaxed) { - feed_block(&state, comp, &feed, &span, rate, mix); + feed_block(&state, comp, &feed, &span, timeline, mix); } else { std::thread::sleep(std::time::Duration::from_millis(4)); } @@ -262,7 +292,7 @@ impl AudioPlayback { .map_err(|e| e.to_string())? }; device.start(feed.clone())?; - Ok(AudioPlayback { feed, device, stop, thread: Some(thread), state, comp, mix, rate, span }) + Ok(AudioPlayback { feed, device, stop, thread: Some(thread), state, comp, mix, rate, span, timeline }) } /// Every UI frame while playing: on wasm32 mix ahead into the feed (what the feeder thread @@ -271,7 +301,7 @@ impl AudioPlayback { if cfg!(target_arch = "wasm32") { let target = queue_target(self.rate); while self.feed.queued_frames() < target && !self.feed.finished.load(Ordering::Relaxed) { - feed_block(&self.state, self.comp, &self.feed, &self.span, self.rate, self.mix); + feed_block(&self.state, self.comp, &self.feed, &self.span, self.timeline, self.mix); } } self.device.pump(); @@ -310,9 +340,15 @@ impl AudioPlayback { } } - /// The comp sample index audible now. + /// The comp sample index audible now, expressed at the device's rate. pub fn clock(&self) -> i64 { - clock_sample(&self.span, self.feed.consumed(), self.device.latency_frames()) + let sample = clock_sample(&self.span, self.feed.consumed(), self.device.latency_frames()); + if self.timeline.normal() { sample } else { self.timeline.time(sample).to_units_floor(i64::from(self.rate)) } + } + + /// Composition time under the audible output sample, including preview speed and latency. + pub fn clock_time(&self) -> Tick { + self.timeline.time(clock_sample(&self.span, self.feed.consumed(), self.device.latency_frames())) } pub fn finished(&self) -> bool { @@ -399,7 +435,7 @@ fn audio_changed_inner(old: &Project, new: &Project, comp: ItemId, seen: &mut Ha /// Mix one block at the feeder's cursor into the feed and move the cursor on (wrapping or /// finishing at the work-area end). The block is mixed without holding the lock, so a project /// change never waits for a slow mix; a block mixed from the old project is dropped. -fn feed_block(state: &Mutex, comp: ItemId, feed: &AudioFeed, span: &PlaySpan, rate: u32, mix: u32) { +fn feed_block(state: &Mutex, comp: ItemId, feed: &AudioFeed, span: &PlaySpan, timeline: PreviewRate, mix: u32) { let Ok((src, mut cursor, generation)) = state.lock().map(|s| (s.src.clone(), s.cursor, s.generation)) else { // Poisoned: nothing more can be mixed. feed.finished.store(true, Ordering::Relaxed); @@ -410,8 +446,14 @@ fn feed_block(state: &Mutex, comp: ItemId, feed: &AudioFeed, span: &Play cursor = span.wa; } if cursor < span.wb { - let n = ((span.wb - cursor) as usize).min(BLOCK); - block = Some(mix_block(&src, comp, sample_time(cursor, rate), n, rate, mix)); + let n = span.wb.saturating_sub(cursor).clamp(0, BLOCK as i64) as usize; + block = Some(if timeline.normal() { + mix_block(&src, comp, timeline.time(cursor), n, timeline.rate(), mix) + } else { + timeline.mix(cursor, n, mix, |t, frames| { + effectcraft_engine::render::audio::mix_comp(&src.project, src.footage.as_ref(), src.expr.as_deref(), comp, t, frames, mix) + }) + }); cursor += n as i64; } let Ok(mut s) = state.lock() else { return }; @@ -587,6 +629,13 @@ mod tests { assert_eq!(clock_sample(&once, 100_000, 0), 47999); } + #[test] + fn slow_preview_clocks_do_not_overflow_a_wide_output_span() { + let span = PlaySpan { start: 0, wa: i64::MIN, wb: i64::MAX, looping: true }; + assert_eq!(clock_sample(&span, u64::MAX, 0), 0); + assert_eq!(clock_sample(&PlaySpan { looping: false, ..span }, u64::MAX, 0), i64::MAX - 1); + } + #[test] fn frame_mapping_matches_comp_frames() { let fps = FrameRate::new(30, 1); diff --git a/crates/ui-egui/src/audio/live_tests.rs b/crates/ui-egui/src/audio/live_tests.rs index 6ca45caac..a7b845c3b 100644 --- a/crates/ui-egui/src/audio/live_tests.rs +++ b/crates/ui-egui/src/audio/live_tests.rs @@ -1,6 +1,6 @@ //! #2104: exercise the real mixer and queue without relying on thread timing or an audio device. -use super::{AudioDevice, AudioFeed, AudioPlayback, Feeder, PlaySpan, feed_block}; +use super::{AudioDevice, AudioFeed, AudioPlayback, Feeder, PlaySpan, PreviewRate, feed_block}; use crate::frames::RenderSource; use effectcraft_engine::color::Label; use effectcraft_engine::keyframe::Value; @@ -62,12 +62,13 @@ fn playback(footage: Arc) -> AudioPlayback { mix: 48000, rate: 48000, span: PlaySpan { start: 24, wa: 16, wb: 64, looping: false }, + timeline: PreviewRate::new(48000, 1.0, Tick::ZERO).unwrap(), } } /// Run exactly one production feeder block, without a background refill racing assertions. fn mix(p: &AudioPlayback) { - feed_block(&p.state, p.comp, &p.feed, &p.span, p.rate, p.mix); + feed_block(&p.state, p.comp, &p.feed, &p.span, p.timeline, p.mix); } /// Copy the active project and apply a layer edit through the same snapshot boundary as the UI. @@ -133,7 +134,7 @@ fn a_block_in_flight_cannot_publish_after_a_mute() { let (resume, gate) = mpsc::channel(); let mut p = playback(Arc::new(Gated { entered, resume: Mutex::new(gate) })); let (state, feed, span, comp, rate) = (p.state.clone(), p.feed.clone(), p.span, p.comp, p.rate); - let worker = std::thread::spawn(move || feed_block(&state, comp, &feed, &span, rate, rate)); + let worker = std::thread::spawn(move || feed_block(&state, comp, &feed, &span, PreviewRate::new(rate, 1.0, Tick::ZERO).unwrap(), rate)); waiting.recv_timeout(Duration::from_secs(10)).unwrap(); // The edit completes while the mixer is blocked, without waiting on its work. p.follow_project(edit(&p, |l| l.switches.audio = false)); diff --git a/crates/ui-egui/src/audio/preview_rate.rs b/crates/ui-egui/src/audio/preview_rate.rs new file mode 100644 index 000000000..06124b4c9 --- /dev/null +++ b/crates/ui-egui/src/audio/preview_rate.rs @@ -0,0 +1,199 @@ +//! Preview speed changes composition time, not the output device's sample rate. +//! Keep the clock and the resampler on the same rational output-sample grid. + +use effectcraft_engine::time::{FrameRate, TICKS_PER_SECOND, Tick}; + +use super::PlaySpan; + +/// At most this many composition samples are mixed at once. At very high preview speeds, +/// skip samples that no output frame needs instead of allocating the entire source interval. +const SOURCE_PAGE: usize = 4096; + +#[derive(Clone, Copy)] +pub(super) struct PreviewRate { + rate: u32, + speed: FrameRate, + origin: Tick, +} + +impl PreviewRate { + pub(super) fn new(rate: u32, speed: f64, start: Tick) -> Result { + // A billionth keeps the ratio of decimal preview rates and rational comp rates without + // rounding it to an integer sample rate. Reject unrepresentable values, never mute them. + let num = (speed * 1_000_000_000.0).round(); + if rate == 0 || !speed.is_finite() || speed <= 0.0 || num < 1.0 || num >= i64::MAX as f64 { + return Err("The preview playback rate must be finite, positive and representable".into()); + } + let speed = FrameRate::new(num as i64, 1_000_000_000); + // Keep the existing 1x sample grid. At another speed, anchor the grid to the exact + // requested frame rather than rounding a nonzero start backwards by an output sample. + let origin = if speed.num == speed.den { Tick::ZERO } else { start }; + Ok(Self { rate, speed, origin }) + } + + pub(super) fn rate(self) -> u32 { + self.rate + } + + pub(super) fn normal(self) -> bool { + self.speed.num == self.speed.den + } + + pub(super) fn time(self, sample: i64) -> Tick { + if self.normal() { + // Preserve the legacy grid, including negative samples at unusual device rates. + return super::sample_time(sample, self.rate); + } + self.origin + Tick::from_rational(sample, self.speed.num, i64::from(self.rate) * self.speed.den) + } + + /// First output sample at or after a composition time. Ceiling keeps loop endpoints inside + /// the work area. A fractional loop boundary is quantized by less than one output sample. + fn sample_ceil(self, time: Tick) -> i64 { + let n = i128::from((time - self.origin).0) * i128::from(self.rate) * i128::from(self.speed.den); + let d = i128::from(TICKS_PER_SECOND) * i128::from(self.speed.num); + let sample = n.div_euclid(d) + i128::from(n.rem_euclid(d) != 0); + sample.clamp(i64::MIN as i128, i64::MAX as i128) as i64 + } + + pub(super) fn span(self, start: Tick, wa: Tick, wb: Tick, looping: bool) -> PlaySpan { + if self.normal() { + return PlaySpan::new(start, wa, wb, looping, self.rate); + } + let wa = self.sample_ceil(wa); + PlaySpan { start: 0, wa, wb: self.sample_ceil(wb).max(wa.saturating_add(1)), looping } + } + + /// Source sample and interpolation phase at an absolute output cursor. Computing this + /// from the timeline (not by accumulating block lengths) makes partitioning immaterial. + fn position(self, sample: i64, mix: u32) -> (i64, f32) { + let n = i128::from(self.time(sample).0) * i128::from(mix); + let d = i128::from(TICKS_PER_SECOND); + (n.div_euclid(d) as i64, n.rem_euclid(d) as f32 / d as f32) + } + + pub(super) fn mix(self, cursor: i64, n: usize, mix: u32, mut render: impl FnMut(Tick, usize) -> Vec) -> Vec { + let mut out = Vec::with_capacity(n.saturating_mul(2)); + let mut page = Vec::new(); + let mut first = 0; + for i in 0..n { + let sample = cursor.saturating_add(i as i64); + let (at, fraction) = self.position(sample, mix); + let offset = usize::try_from(at.saturating_sub(first)).ok(); + if page.is_empty() || offset.is_none_or(|i| i >= (page.len() / 2).saturating_sub(1)) { + first = at; + let last = self.position(cursor.saturating_add(n.saturating_sub(1) as i64), mix).0; + let next = self.position(sample.saturating_add(1), mix).0; + let frames = if next.saturating_sub(first) >= SOURCE_PAGE as i64 - 1 { + // No following output sample can reuse this page. Read only this sample + // and its interpolation neighbor, rather than mixing an unused gap. + 2 + } else { + last.saturating_sub(first).saturating_add(2).clamp(2, SOURCE_PAGE as i64) as usize + }; + // mix_comp floors its starting sample. Add the fractional tick back so exotic + // source rates start on `first`, not the preceding composition sample. + let mut t = Tick::from_units(first, i64::from(mix)); + if t.to_units_floor(i64::from(mix)) < first { + t.0 = t.0.saturating_add(1); + } + page = render(t, frames); + } + let index = usize::try_from(at.saturating_sub(first)).unwrap_or(usize::MAX).saturating_mul(2); + for channel in 0..2 { + let a = page.get(index.saturating_add(channel)).copied().unwrap_or(0.0); + let b = page.get(index.saturating_add(2 + channel)).copied().unwrap_or(a); + out.push(a + (b - a) * fraction); + } + } + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::audio::clock_sample; + + #[test] + fn normal_speed_preserves_the_legacy_sample_grid() { + for rate in [44_100, 48_000, 48_001] { + let timeline = PreviewRate::new(rate, 1.0, Tick::from_seconds_f64(-0.3)).unwrap(); + for sample in [-48_001, -1, 0, 1, 48_001] { + assert_eq!(timeline.time(sample), crate::audio::sample_time(sample, rate)); + } + } + } + + #[test] + fn speed_clock_matches_slow_fast_and_fractional_previews() { + for (comp, preview) in [(FrameRate::FPS_60, 30.0), (FrameRate::FPS_30, 60.0), (FrameRate::FPS_59_94, 29.97)] { + let speed = preview / comp.as_f64(); + let start = comp.tick_of(17); + let rate = PreviewRate::new(48_000, speed, start).unwrap(); + assert_eq!(rate.time(0), start, "the first frame stays exact"); + let time = rate.time(48_000); + assert!(((time - start).seconds() - speed).abs() < 1e-9); + assert_eq!(comp.frame_at(time), comp.frame_at(start + Tick::from_seconds_f64(speed))); + } + } + + #[test] + fn speed_clock_subtracts_output_latency_and_wraps_inside_the_work_area() { + for speed in [0.5, 2.0, 0.73] { + let start = FrameRate::FPS_29_97.tick_of(19); + let wa = FrameRate::FPS_29_97.tick_of(13); + let wb = FrameRate::FPS_29_97.tick_of(43); + let rate = PreviewRate::new(48_000, speed, start).unwrap(); + let span = rate.span(start, wa, wb, true); + assert_eq!(rate.time(clock_sample(&span, 512, 1024)), start); + assert!(rate.time(span.wb - 1) < wb); + let wrapped = rate.time(clock_sample(&span, span.wb as u64 + 1024, 1024)); + assert!(wrapped >= wa && (wrapped - wa).seconds() < speed / 48_000.0); + let once = PlaySpan { looping: false, ..span }; + assert!(rate.time(clock_sample(&once, u32::MAX as u64, 0)) < wb); + } + } + + fn ramp(t: Tick, n: usize, mix: u32) -> Vec { + let first = t.to_units_floor(i64::from(mix)); + (0..n).flat_map(|i| [(first + i as i64) as f32 / 100_000.0, -(first + i as i64) as f32 / 100_000.0]).collect() + } + + #[test] + fn speed_mix_keeps_fractional_phase_across_uneven_blocks() { + for speed in [0.5, 2.0, 0.73, 29.97 / FrameRate::FPS_59_94.as_f64()] { + let start = FrameRate::FPS_29_97.tick_of(17); + let rate = PreviewRate::new(48_000, speed, start).unwrap(); + for mix in [44_100, 48_000, 96_000] { + let whole = rate.mix(0, 2048, mix, |t, n| ramp(t, n, mix)); + let split: Vec<_> = [(0, 321), (321, 107), (428, 1620)].into_iter().flat_map(|(at, n)| rate.mix(at, n, mix, |t, n| ramp(t, n, mix))).collect(); + assert_eq!(whole, split, "speed {speed}, source rate {mix}"); + for (i, frame) in whole.as_chunks::<2>().0.iter().enumerate() { + let expected = (start.seconds() + i as f64 * speed / 48_000.0) * mix as f64 / 100_000.0; + assert!((f64::from(frame[0]) - expected).abs() < 1e-6); + assert_eq!(frame[1], -frame[0]); + } + } + } + } + + #[test] + fn extreme_speeds_keep_source_reads_bounded_without_changing_speed() { + for speed in [0.00001, 99_900.0] { + let rate = PreviewRate::new(8_000, speed, Tick::ZERO).unwrap(); + let mut rendered = 0; + let out = rate.mix(0, 1024, 192_000, |_, n| { + assert!(n <= SOURCE_PAGE); + rendered += n; + vec![0.25; n * 2] + }); + assert!(rendered <= SOURCE_PAGE, "unused source intervals were mixed: {rendered} samples"); + assert_eq!(out, vec![0.25; 2048]); + assert!((rate.time(8000).seconds() - speed).abs() < 1e-9); + } + for speed in [0.0, -1.0, f64::NAN, f64::INFINITY, f64::MAX] { + assert!(PreviewRate::new(48_000, speed, Tick::ZERO).is_err()); + } + } +} diff --git a/crates/ui-egui/tests/it/ui_audio_scrub.rs b/crates/ui-egui/tests/it/ui_audio_scrub.rs index ae724c9ad..f6baa0443 100644 --- a/crates/ui-egui/tests/it/ui_audio_scrub.rs +++ b/crates/ui-egui/tests/it/ui_audio_scrub.rs @@ -783,3 +783,162 @@ fn unmuting_a_silent_preview_starts_audio_at_its_current_frame() { h.state_mut().stop(); } } + +/// Pull only queued frames: underruns are deliberately excluded so the test measures the +/// feeder's composition mapping, not how quickly a loaded test host schedules its thread. +fn take_preview_samples(app: &EffectcraftApp, frames: usize) -> Vec { + let audio = app.audio.as_ref().expect("preview audio opened"); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + let mut samples = Vec::with_capacity(frames * 2); + while samples.len() < frames * 2 { + let n = audio.feed.queued_frames().min(frames - samples.len() / 2); + if n == 0 { + assert!(!audio.finished(), "the preview ended before the requested samples"); + assert!(std::time::Instant::now() < deadline, "audio feeder timed out"); + std::thread::sleep(std::time::Duration::from_millis(1)); + continue; + } + let mut block = vec![0.0; n * 2]; + audio.feed.pull(&mut block); + samples.extend(block); + } + samples +} + +#[test] +fn preview_rate_audio_sounds_at_the_selected_speed_and_finishes_at_the_end() { + use effectcraft_engine::preview::{PlayFrom, PreviewRange}; + for (comp_fps, preview_fps, duration_frames) in [(60.0, 30.0, 96_000), (30.0, 60.0, 24_000)] { + let fake = Fake::default(); + let (mut app, cid) = setup_long(true, &fake, 1.0); + app.session.execute("comp.settings", json!({"comp": cid.0, "frameRate": comp_fps})).unwrap(); + let p = &mut app.session.prefs.preview.spacebar; + p.include_video = false; + p.frame_rate = Some(preview_fps); + p.loop_ = false; + p.range = PreviewRange::EntireDuration; + p.play_from = PlayFrom::RangeStart; + app.play(0.0); + assert!(app.audio.is_some(), "a frame-rate override must not mute the preview"); + let samples = take_preview_samples(&app, 12_000); + assert!(samples.iter().any(|v| v.abs() > 0.01)); + let audio = app.audio.as_ref().unwrap(); + assert_eq!(audio.rate, 48_000, "the physical output rate is unchanged"); + assert!((audio.clock_time().seconds() - 0.25 * preview_fps / comp_fps).abs() < 1e-9); + take_preview_samples(&app, duration_frames - 12_000); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + while !app.audio.as_ref().unwrap().finished() { + assert!(std::time::Instant::now() < deadline, "one-shot audio did not finish"); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + assert!(app.audio.as_ref().unwrap().clock_time().seconds() < 1.0); + app.stop(); + } +} + +#[test] +fn preview_rate_audio_resamples_the_sound_instead_of_only_changing_the_clock() { + use effectcraft_engine::preview::{PlayFrom, PreviewRange}; + let render = |preview_fps, frames| { + let fake = Fake::default(); + let (mut app, _) = setup(true, &fake); + let p = &mut app.session.prefs.preview.spacebar; + p.include_video = false; + p.frame_rate = Some(preview_fps); + p.range = PreviewRange::EntireDuration; + p.play_from = PlayFrom::RangeStart; + app.session.prefs.audio.preview_sample_rate = 48_000; + app.play(0.0); + let samples = take_preview_samples(&app, frames); + app.stop(); + samples + }; + let normal = render(30.0, 2048); + let slow = render(15.0, 4096); + for (original, slowed) in normal.as_chunks::<2>().0.iter().zip(slow.as_chunks::<4>().0) { + assert!((original[0] - slowed[0]).abs() < 1e-5); + assert!((original[1] - slowed[1]).abs() < 1e-5); + } +} + +#[test] +fn preview_rate_audio_drives_video_at_half_speed_and_loops() { + let fake = Fake::default(); + let (mut app, cid) = setup_long(true, &fake, 1.0); + app.session.execute("comp.settings", json!({"comp": cid.0, "frameRate": 60})).unwrap(); + app.session.prefs.preview.spacebar.frame_rate = Some(30.0); + app.session.prefs.preview.spacebar.loop_ = true; + let gate = GatedFrames::default(); + app.frames.set_remote(Some(Arc::new(gate.clone()))); + let ahead = (app.frames.parallelism() * 2).max(4) as i64; + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_| app); + h.state_mut().use_manual_clock(); + h.run_steps(2); + gate.discard(&mut h); + h.state_mut().play(0.0); + assert!(h.state().playback.starting, "mismatched-rate audio waits with the first picture"); + let frames: Vec = (0..ahead.min(60)).collect(); + gate.render(&mut h, cid, &frames); + step(&mut h); + assert!(h.state().audio.is_some()); + take_preview_samples(h.state(), 4800); + step(&mut h); + assert_eq!(frame(&h, cid), 3, "0.1 output seconds is 0.05 composition seconds"); + take_preview_samples(h.state(), 96_000 - 4800); + step(&mut h); + assert_eq!(frame(&h, cid), 0, "the audio and picture wrap together"); + assert!(!h.state().audio.as_ref().unwrap().finished()); + h.state_mut().stop(); +} + +#[test] +fn preview_rate_audio_keeps_the_include_audio_switch_and_skip_independent() { + let fake = Fake::default(); + let (mut app, _) = setup(true, &fake); + app.session.prefs.preview.spacebar.include_audio = false; + app.session.prefs.preview.spacebar.frame_rate = Some(60.0); + app.play(0.0); + assert!(app.audio.is_none() && !app.playback.audio_held && !app.playback.starting); + app.stop(); + let p = &mut app.session.prefs.preview.spacebar; + p.include_audio = true; + p.include_video = false; + p.skip = 2; + app.play(0.0); + take_preview_samples(&app, 12_000); + assert_eq!(app.audio.as_ref().unwrap().clock_time(), Tick::from_seconds_f64(0.5), "Skip drops pictures, not audio or time"); + app.stop(); +} + +/// Live edits keep the consumed output cursor at either speed, including a nonzero start. +#[test] +fn preview_rate_audio_keeps_its_position_through_live_mute_and_unmute() { + use effectcraft_engine::preview::{PlayFrom, PreviewRange}; + for preview_fps in [15.0, 60.0] { + let fake = Fake::default(); + let (mut app, cid) = setup(true, &fake); + let comp = app.session.project.comp(cid).unwrap(); + let layer = comp.layers[0].id.0; + let start = comp.frame_rate.tick_of(17); + app.session.set_time(start); + let p = &mut app.session.prefs.preview.spacebar; + p.include_video = false; + p.frame_rate = Some(preview_fps); + p.range = PreviewRange::EntireDuration; + p.play_from = PlayFrom::CurrentTime; + app.play(0.0); + assert_eq!(app.audio.as_ref().unwrap().clock_time(), start); + for (edit, audible) in [(None, true), (Some(false), false), (Some(true), true)] { + if let Some(value) = edit { + app.session.execute("layer.setSwitch", json!({"layers": [layer], "switch": "audio", "value": value})).unwrap(); + app.audio.as_mut().unwrap().follow_project(app.session.project.clone()); + } + let before = app.audio.as_ref().unwrap().clock_time(); + let samples = take_preview_samples(&app, 2400); + assert_eq!(samples.iter().any(|v| v.abs() > 0.01), audible); + let elapsed = (app.audio.as_ref().unwrap().clock_time() - before).seconds(); + assert!((elapsed - 0.05 * preview_fps / 30.0).abs() < 1e-9, "live edit changed the preview cursor"); + } + app.stop(); + } +}