mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-10 20:41:25 +00:00
1 parent
13470ca161
commit
9143393ce5
7 files changed
+448
-31
No files matched your search
@@ -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<f64>,
|
||||
/// Frames skipped between the frames played (0 = every frame).
|
||||
pub skip: u32,
|
||||
|
||||
@@ -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}"),
|
||||
}
|
||||
|
||||
@@ -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<i64> {
|
||||
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
|
||||
|
||||
+68
-19
@@ -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<dyn AudioDevice>,
|
||||
device: Box<dyn AudioDevice>,
|
||||
src: RenderSource,
|
||||
comp: ItemId,
|
||||
start: Tick,
|
||||
@@ -227,16 +239,34 @@ impl AudioPlayback {
|
||||
wb: Tick,
|
||||
looping: bool,
|
||||
mix_rate: u32,
|
||||
) -> Result<AudioPlayback, String> {
|
||||
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<dyn AudioDevice>,
|
||||
src: RenderSource,
|
||||
comp: ItemId,
|
||||
start: Tick,
|
||||
wa: Tick,
|
||||
wb: Tick,
|
||||
looping: bool,
|
||||
mix_rate: u32,
|
||||
speed: f64,
|
||||
) -> Result<AudioPlayback, String> {
|
||||
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<Feeder>, comp: ItemId, feed: &AudioFeed, span: &PlaySpan, rate: u32, mix: u32) {
|
||||
fn feed_block(state: &Mutex<Feeder>, 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<Feeder>, 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);
|
||||
|
||||
@@ -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<dyn FootageSource>) -> 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));
|
||||
|
||||
@@ -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<Self, String> {
|
||||
// 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<f32>) -> Vec<f32> {
|
||||
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<f32> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<f32> {
|
||||
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<i64> = (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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user