diff --git a/crates/ui-egui/src/app/playback.rs b/crates/ui-egui/src/app/playback.rs index 912a196f8..d40bc69ff 100644 --- a/crates/ui-egui/src/app/playback.rs +++ b/crates/ui-egui/src/app/playback.rs @@ -40,11 +40,16 @@ impl EffectcraftApp { restore_max, audio_frame: (!plan.video).then_some(plan.first), audio_held: false, + starting: false, + starting_since: now, + first_cached: None, shown_at: Default::default(), }; if !self.playback.caching { if plan.video { - self.playback.audio_held = self.audio_plays(); + let audible = self.audio_plays(); + self.playback.audio_held = audible; + self.playback.starting = audible; } else { self.start_audio(fr.tick_of(plan.first)); } @@ -104,6 +109,7 @@ impl EffectcraftApp { let was = std::mem::replace(&mut self.playback.playing, false); self.audio = None; self.playback.caching = false; + self.playback.starting = false; if !was { return; } diff --git a/crates/ui-egui/src/app/prefetch.rs b/crates/ui-egui/src/app/prefetch.rs index 1fdf0aad1..e1b953d7b 100644 --- a/crates/ui-egui/src/app/prefetch.rs +++ b/crates/ui-egui/src/app/prefetch.rs @@ -4,6 +4,29 @@ use super::{EffectcraftApp, FrameKey, FrameWindow, ItemId, PlaybackPass, audio, /// [`EffectcraftApp::editing`]). const EDIT_QUIET: f64 = 0.3; +/// Longest a preview with sound holds its first frame, once that frame is cached, waiting for the +/// frames ahead (seconds, #947). A frame ahead that never gets cached mustn't hold it for ever: +/// the sound starts from the first frame anyway, and the preview goes on as a stalled one does. +const STARTUP_WAIT: f64 = 2.0; + +/// Longest a preview with sound holds its first frame in all (seconds, #947). The first frame +/// itself can take a while (a heavy comp, a busy machine), and [`STARTUP_WAIT`] counts only from +/// when it is cached; but one that never gets cached (larger than the RAM preview, which the size +/// estimate can't always tell) mustn't hold it for ever. The preview then goes on as a stalled +/// one does: its sound starts once the frames are cached. +const STARTUP_LIMIT: f64 = 10.0; + +/// How many frames from the first a preview with sound waits for before it starts (#947): the +/// prefetch's `ahead`, but no more than its `window` requests from the current frame (offsets +/// `0..window`), and no more than fit in the RAM preview `budget` (90 %, as for the prefetch) at +/// `frame_bytes`, the size frames really render at; at least the current frame, which the viewer +/// always requests. Waiting for a frame that is never requested, or that can't stay cached with +/// the others, would hold the first frame for ever. +fn startup_frames(ahead: i64, window: i64, budget: usize, frame_bytes: usize) -> i64 { + let fit = (budget as f64 * 0.9 / frame_bytes.max(1) as f64) as i64; + ahead.min(window).min(fit).max(1) +} + impl EffectcraftApp { /// RAM preview: advance at the preview's frame rate when frames are cached; otherwise hold /// and keep rendering ahead (the green cache bar fills, then playback runs in real time). @@ -53,6 +76,9 @@ impl EffectcraftApp { return; } let fit = self.frames_that_fit(&c, scale); + if self.hold_first_frame(&pass, fit) { + return; + } self.start_held_audio(&pass, fit); if self.advance_audio_clock(&pass, step) { return; @@ -179,8 +205,54 @@ impl EffectcraftApp { } } - /// Held sound starts once the frames ahead are cached (see `Playback::audio_held`): the - /// prefetch window when the silent preview keeps real time, else the rest of the preview. + /// Starting a preview with sound: hold the first frame until the frames ahead are cached, then + /// start the sound from that same frame (see `Playback::starting`). Without an output device + /// the picture plays on its own clock from it. How many frames ahead: only ones + /// `prefetch_playback` requests and that fit in the RAM preview at the size they really render + /// (an Extended Viewer frame is larger than the comp), so the wait always ends. Returns true + /// while it holds the first frame. + fn hold_first_frame(&mut self, pass: &PlaybackPass<'_>, fit: i64) -> bool { + let PlaybackPass { ctx, comp, scale, now, plan, series, cur, ahead, frame_rate: fr, .. } = *pass; + if !self.playback.starting { + return false; + } + let Some(p) = plan else { return false }; + self.playback.start_wall = now; + self.playback.start_frame = cur; + // `prefetch_playback` requests every uncached frame of offsets 0..window from here. + let window = (ahead * 3).min(fit - 1); + let need = self.frames.get(&FrameKey { frame: cur, ..series }).map(|img| { + let [w, h] = img.size(); + startup_frames(ahead, window, self.frames.budget(), w.saturating_mul(h).saturating_mul(4)) + }); + if need.is_some() && self.playback.first_cached.is_none() { + self.playback.first_cached = Some(now); + } + // Don't wait for frames the RAM preview can't hold (not even one frame fits), for the + // frames ahead longer than STARTUP_WAIT once the first is in, nor longer than + // STARTUP_LIMIT in all (a frame that never gets cached). + let give_up = (self.frames.budget() as f64) < self.frame_bytes(comp, scale) + || self.playback.first_cached.is_some_and(|t| now - t >= STARTUP_WAIT) + || now - self.playback.starting_since >= STARTUP_LIMIT; + if !give_up && !need.is_some_and(|n| self.cached_ahead(&p, &series, cur, n)) { + self.playback.waiting = true; + ctx.request_repaint(); + return true; + } + self.playback.starting = false; + self.playback.waiting = false; + // Not on a frame that isn't cached: the stall below would stop the sound at once. The + // preview goes on as a stalled one does, and the sound starts once frames are cached. + if need.is_some() { + self.playback.audio_held = false; + self.start_audio(fr.tick_of(cur)); + } + false + } + + /// Held sound starts once the frames ahead are cached again after a stall (see + /// `Playback::audio_held`): the prefetch window when the silent preview keeps real time, else + /// the rest of the preview. fn start_held_audio(&mut self, pass: &PlaybackPass<'_>, fit: i64) { let PlaybackPass { now, plan, series, cur, ahead, frame_rate: fr, .. } = *pass; if !self.playback.audio_held { @@ -365,9 +437,13 @@ impl EffectcraftApp { } fn frames_that_fit(&self, comp: &effectcraft_engine::project::Comp, scale: f64) -> i64 { + ((self.frames.budget() as f64 * 0.9 / self.frame_bytes(comp, scale)) as i64).max(1) + } + + /// Bytes of one comp-sized viewer frame at `scale` (Extended Viewer frames are larger). + fn frame_bytes(&self, comp: &effectcraft_engine::project::Comp, scale: f64) -> f64 { let side = |n: u32| (n as f64 * scale).ceil().max(1.0); - let frame = side(comp.width) * side(comp.height) * 4.0; - ((self.frames.budget() as f64 * 0.9 / frame) as i64).max(1) + side(comp.width) * side(comp.height) * 4.0 } fn frames_parallelism(&self) -> usize { @@ -379,3 +455,28 @@ impl EffectcraftApp { self.frames.parallelism() } } + +#[cfg(test)] +mod startup_tests { + use super::startup_frames; + + /// The frames a preview with sound waits for are always requested and fit together (#947). + #[test] + fn startup_waits_only_for_frames_it_requests_and_can_keep() { + // A 64×36 frame: 9216 bytes. A 46 080-byte budget fits 4 of them (90 %); the prefetch + // window then requests offsets 0..3, so the start needs those 3, not 4 it never asks for. + let frame = 64 * 36 * 4; + let fit = 4; + let window = (32i64 * 3).min(fit - 1); + assert_eq!(startup_frames(32, window, 46_080, frame), 3); + // A roomy budget: the prefetch's `ahead` frames. + assert_eq!(startup_frames(8, 24, 100_000_000, frame), 8); + // Extended Viewer: a 160×120 comp renders 320×248 frames. 952 320 bytes fit 11 comp-sized + // frames but only 2 real ones (90 %): wait for 2, not 4. + assert_eq!(startup_frames(4, 11, 952_320, 320 * 248 * 4), 2); + // Nothing fits, or no window: still the current frame, never 0. + assert_eq!(startup_frames(8, 0, 0, frame), 1); + // A zero frame size counts as 1 byte: no division by zero. + assert_eq!(startup_frames(8, 24, 10, 0), 8); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fcbfd49bf..0b259bfde 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -184,6 +184,17 @@ pub struct Playback { /// fits in the RAM preview) is cached, so it never plays over skipped frames (#103), or /// sooner once the silent preview keeps real time with the frames ahead cached (#208). pub audio_held: bool, + /// Starting a preview with sound (#947): the playhead stays on the first frame while the + /// frames ahead render (the prefetch window, at most what fits in the RAM preview), then + /// sound and picture start together from it. Without this the picture played on silently + /// while they rendered and the sound started at the later frame, so the opening was lost. + /// A stall later on still falls back to [`Playback::audio_held`]. + pub starting: bool, + /// When the preview started (seconds): [`Playback::starting`] gives up after `STARTUP_LIMIT`. + pub starting_since: f64, + /// When the first frame was cached while [`Playback::starting`] (seconds): the wait for the + /// frames ahead gives up `STARTUP_WAIT` after it. + pub first_cached: Option, /// When the last frames were shown (seconds, the last second's worth): the achieved rate. pub shown_at: std::collections::VecDeque, } diff --git a/crates/ui-egui/tests/it/ui_audio_scrub.rs b/crates/ui-egui/tests/it/ui_audio_scrub.rs index c748afe8e..8596d2506 100644 --- a/crates/ui-egui/tests/it/ui_audio_scrub.rs +++ b/crates/ui-egui/tests/it/ui_audio_scrub.rs @@ -166,96 +166,90 @@ fn cmd_dragging_the_current_time_scrubs_and_an_idle_scrub_closes() { assert!(h.state().scrub.is_none() && fake.0.lock().unwrap().1, "closed and stopped"); } -/// A preview with audio waits for frames like a silent one (#103): while the frames ahead aren't -/// cached, every frame shows as it renders, silently; the sound starts once the rest of the -/// preview is cached, and a frame that isn't cached stops it instead of being skipped. +/// The comp's frame at the current time. +fn frame(h: &Harness<'_, EffectcraftApp>, cid: ItemId) -> i64 { + let app = h.state(); + app.session.project.comp(cid).unwrap().frame_rate.frame_at(app.session.time()) +} + +/// The frame under the preview audio's first sample (its clock before the device pulls any). +fn audio_frame(h: &Harness<'_, EffectcraftApp>, cid: ItemId) -> Option { + let app = h.state(); + let a = app.audio.as_ref()?; + Some(effectcraft_ui_egui::audio::frame_of_sample(a.clock(), a.rate, app.session.project.comp(cid).unwrap().frame_rate)) +} + +/// Step until the preview's sound starts (or 60 s pass, for a loaded machine), checking the +/// playhead holds `first` meanwhile. Returns whether the sound started. +fn until_sound(h: &mut Harness<'_, EffectcraftApp>, cid: ItemId, first: i64) -> bool { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + while std::time::Instant::now() < deadline { + step(h); + if h.state().audio.is_some() { + return true; + } + assert_eq!(frame(h, cid), first, "the playhead holds the first frame until the sound can start"); + assert!(h.state().playback.starting && h.state().playback.playing); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + false +} + +/// One step with the app clock, which playback runs on, a step (1/30 s) further on: these tests +/// count time in steps, however long a step takes on a loaded machine. +fn step(h: &mut Harness<'_, EffectcraftApp>) { + h.state_mut().clock_mut().advance(1.0 / 30.0); + h.step(); +} + +/// Starting a preview with sound before its frames are cached (#947): the playhead holds the +/// first frame while the frames ahead render, then sound and picture start together from it (the +/// sound used to start at a later frame, losing the opening). A frame that isn't cached later on +/// still stops the sound rather than being skipped (#103). #[test] -fn preview_with_audio_shows_every_frame_and_sounds_once_cached() { +fn preview_with_audio_holds_the_first_frame_and_sounds_from_it() { let fake = Fake::default(); let (mut app, cid) = setup(true, &fake); - // Hold rendering in the test: the cache cannot fill ahead of what the test releases, so the - // silently shown frames and the moment the sound starts do not depend on the host's threads. + // Release only the frames this test chooses, and drive the app clock: rendering cannot race + // the clock on a slow or loaded test host. let gate = GatedFrames::default(); let renderer: Arc = Arc::new(gate.clone()); app.frames.set_remote(Some(renderer)); - let ahead = (app.frames.parallelism() * 2).max(4); + let ahead = (app.frames.parallelism() * 2).max(4) as i64; app.session.execute("comp.settings", json!({"comp": cid.0, "duration": (4 * ahead) as f64 / 30.0})).unwrap(); let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 30.0).build_eframe(|_| app); - // Playback measures time on the app clock, never `ctx.input(|i| i.time)`; drive that clock - // explicitly at one frame per step. h.state_mut().use_manual_clock(); h.run_steps(2); gate.discard(&mut h); - let frame = |h: &Harness<'_, EffectcraftApp>| { - let app = h.state(); - app.session.project.comp(cid).unwrap().frame_rate.frame_at(app.session.time()) - }; let now = h.state().now(); h.state_mut().play(now); - assert!(h.state().playback.audio_held && h.state().audio.is_none(), "nothing is cached: no sound yet"); - let mut last = frame(&h); - let mut shown = 0; - // Show frames while only those frames are cached: they play silently first. - for _ in 0..4 { - let target = last + 1; - gate.render(&mut h, cid, &[target]); - h.state_mut().clock_mut().advance(1.0 / 30.0); - h.step(); - let f = frame(&h); - // Accumulating 1/30 in f64 can leave the same frame up for one more step; it must never - // jump over a frame. - assert!(f == last || f == last + 1, "{last} → {f}: a frame was skipped"); - shown += usize::from(f != last); - last = f; - } - // A frame that has not rendered holds the clock at the frame on screen; once it renders it is - // shown, never jumped over. This exercises the production hold path, not a test-only shortcut. - let waiting = last; - h.state_mut().clock_mut().advance(1.0 / 30.0); - h.step(); - assert_eq!(frame(&h), waiting, "an uncached frame holds the preview instead of being skipped"); - gate.render(&mut h, cid, &[waiting + 1]); - // f64 clock accumulation need not put the next step exactly on the next frame boundary; - // keep stepping until the held frame appears, and never allow it to jump past that frame. - let mut showed_waiting = false; - for _ in 0..4 { - h.state_mut().clock_mut().advance(1.0 / 30.0); - h.step(); - let f = frame(&h); - assert!(f == waiting || f == waiting + 1, "{waiting} → {f}: the held frame was skipped"); - if f == waiting + 1 { - showed_waiting = true; - break; - } - } - assert!(showed_waiting, "the waiting frame is shown once it renders"); - shown += 1; - last = waiting + 1; - assert!(shown > 0, "frames played silently first"); - assert!(h.state().audio.is_none() && h.state().playback.audio_held, "the rest of the range is not cached: the sound waits"); - // Cache everything ahead; only then may the sound start. - let end = h.state().playback.plan.expect("a preview plan").end; - let rest: Vec = ((last + 1)..=end).collect(); - gate.render(&mut h, cid, &rest); - h.step(); + assert!(h.state().playback.starting && h.state().audio.is_none(), "nothing is cached: no sound yet"); + // Half the frames ahead cached: the playhead holds the first frame, silently. + let half: Vec = (0..ahead / 2).collect(); + gate.render(&mut h, cid, &half); + step(&mut h); + assert!(h.state().playback.starting && h.state().audio.is_none(), "{} of {ahead} frames ahead cached: no sound yet", half.len()); + assert_eq!(frame(&h, cid), 0, "the playhead holds the first frame"); + // All of them: the sound starts, from that frame. + let all: Vec = (0..ahead).collect(); + gate.render(&mut h, cid, &all); + step(&mut h); assert!(h.state().audio.is_some() && fake.0.lock().unwrap().0.is_some(), "the sound started once the frames ahead were cached"); + assert_eq!(audio_frame(&h, cid), Some(0), "from the first frame, not a later one"); + assert!(!h.state().playback.starting); // A frame that isn't cached stops the sound rather than being skipped. gate.discard(&mut h); - h.state().frames.set_budget(0); h.state().frames.clear(); h.step(); assert!(h.state().audio.is_none() && h.state().playback.audio_held && h.state().playback.playing); } -/// A preview whose frames render in real time sounds without waiting for the whole work area to -/// be cached (#208): once the silent preview has kept up for a moment with the frames ahead -/// cached, the sound starts, still without skipping a frame before it. +/// A long (30 s) preview sounds once the frames ahead of its first frame are cached, not the +/// whole work area (#208), and from that first frame (#947). #[test] -fn preview_with_audio_sounds_once_rendering_keeps_up() { +fn preview_with_audio_sounds_without_caching_the_work_area() { let fake = Fake::default(); let (mut app, cid) = setup_long(true, &fake, 30.0); - // Release only the frames this test chooses, and drive the app clock: rendering cannot race - // the clock, so a frame is never skipped by a slow or loaded test host. let gate = GatedFrames::default(); let renderer: Arc = Arc::new(gate.clone()); app.frames.set_remote(Some(renderer)); @@ -264,36 +258,396 @@ fn preview_with_audio_sounds_once_rendering_keeps_up() { h.run_steps(2); gate.discard(&mut h); let ahead = (h.state().frames.parallelism() * 2).max(4) as i64; - let frame = |h: &Harness<'_, EffectcraftApp>| { - let app = h.state(); - app.session.project.comp(cid).unwrap().frame_rate.frame_at(app.session.time()) + let now = h.state().now(); + h.state_mut().play(now); + let frames: Vec = (0..ahead).collect(); + gate.render(&mut h, cid, &frames); + step(&mut h); + assert!(h.state().audio.is_some(), "the sound started"); + assert_eq!(audio_frame(&h, cid), Some(0), "from the first frame"); + let cached = h.state().frames.cached_frames(&h.state().shown_series(cid)).len(); + assert!((1..900).contains(&cached), "it started with {cached} of the 900 frames cached, not the whole work area"); +} + +/// Pull `frames` stereo frames from the output, waiting for the feeder thread to mix them. +fn pull(feed: &AudioFeed, frames: usize) -> Vec { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); + while feed.queued_frames() < frames && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + let mut out = vec![0.0f32; frames * 2]; + feed.pull(&mut out); + out +} + +/// The opening of the preview is heard (#947): a sound that lasts only the first 100 ms after the +/// start, from the beginning of the comp or from the middle, reaches the output device. +#[test] +fn a_sound_in_the_first_100_ms_is_heard() { + for start in [0.0, 1.0] { + let fake = Fake::default(); + let (mut app, cid) = setup_long(false, &fake, 4.0); + // A tone only from `start` to `start` + 0.1 s. + let l = app.session.execute("layer.newSolid", json!({"color": "#808080"})).unwrap()["layer"].as_u64().unwrap(); + app.session.execute("effect.apply", json!({"layers": [l], "effect": "ec.audio.tone"})).unwrap(); + app.session.execute("layer.setSwitch", json!({"layers": [l], "switch": "audio", "value": true})).unwrap(); + app.session.execute("layer.timing", json!({"layers": [l], "in": start, "out": start + 0.1})).unwrap(); + app.session.execute("time.set", json!({"time": start})).unwrap(); + let first = (start * 30.0) as i64; + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 30.0).build_eframe(|_| app); + h.state_mut().use_manual_clock(); + h.run_steps(2); + let now = h.state().now(); + h.state_mut().play(now); + assert!(until_sound(&mut h, cid, first), "{start}: the sound started"); + assert_eq!(audio_frame(&h, cid), Some(first), "{start}: from the first frame"); + let feed = fake.0.lock().unwrap().0.clone().expect("the output opened"); + // The first 100 ms at 48 kHz. + let out = pull(&feed, 4800); + assert!(out.iter().any(|v| v.abs() > 0.01), "{start}: the opening tone reached the output"); + } +} + +/// A preview without video starts its sound at once, from the first frame (unchanged), and so +/// does one whose frames are already cached. +#[test] +fn audio_only_previews_start_at_once() { + let fake = Fake::default(); + let (app, cid) = setup(true, &fake); + 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); + let now = h.state().now(); + h.state_mut().play_with(now, effectcraft_engine::preview::PreviewShortcut::AltNumpad0); + assert!(h.state().audio.is_some() && !h.state().playback.starting, "Video off: the sound starts at once"); + assert_eq!(audio_frame(&h, cid), Some(0)); +} + +/// Without an output device the preview still plays, from the first frame, once the frames ahead +/// are cached; Stop while it prepares leaves nothing playing. +#[test] +fn no_device_plays_the_picture_and_stop_ends_the_wait() { + let fake = Fake::default(); + let (mut app, cid) = setup_long(true, &fake, 10.0); + app.hooks.audio_device = Some(Box::new(|_| None)); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 30.0).build_eframe(|_| app); + h.state_mut().use_manual_clock(); + h.run_steps(2); + let now = h.state().now(); + h.state_mut().play(now); + assert!(h.state().playback.starting); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + while h.state().playback.starting && std::time::Instant::now() < deadline { + step(&mut h); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + assert!(!h.state().playback.starting && h.state().audio.is_none() && h.state().playback.playing, "no device: the picture plays alone"); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + while frame(&h, cid) == 0 && std::time::Instant::now() < deadline { + step(&mut h); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(frame(&h, cid) > 0, "from the first frame on"); + // Stop while preparing. + h.state_mut().stop(); + let now = h.state().now(); + let device = fake.clone(); + h.state_mut().hooks.audio_device = Some(Box::new(move |_| Some(Box::new(device.clone()) as Box))); + h.state().frames.clear(); + h.state_mut().play(now); + // One step into preparing: the frames ahead are requested and it waits for them. + step(&mut h); + assert!(h.state().playback.starting && h.state().playback.waiting && h.state().audio.is_none(), "preparing"); + h.state_mut().stop(); + // The renders it queued finish after Stop: they must not start the sound. + for _ in 0..30 { + step(&mut h); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert!(!h.state().playback.playing && !h.state().playback.starting && h.state().audio.is_none(), "Stop while preparing: nothing plays"); + assert!(fake.0.lock().unwrap().0.is_none(), "the output never opened"); +} + +/// Start a preview and step until it leaves its startup (or 60 s pass). Returns whether it did. +fn until_started(h: &mut Harness<'_, EffectcraftApp>) -> bool { + let now = h.state().now(); + h.state_mut().play(now); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + while h.state().playback.starting && std::time::Instant::now() < deadline { + step(h); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + !h.state().playback.starting +} + +/// RAM preview budgets just below, at and above the prefetch's frames ahead, and small ones, from +/// a cold cache, with and without an output device: the start never waits for a frame the +/// prefetch doesn't request (with a budget of 4 frames it asks for 3 ahead of the playhead). +#[test] +fn startup_finishes_at_any_ram_preview_budget() { + let probe = Fake::default(); + let (app, _) = setup(true, &probe); + let ahead = (app.frames.parallelism() * 2).max(4) as i64; + // A 64×36 frame is 9216 bytes; `fit` frames fit in (fit + ½) frames' worth / 0.9. + let mut fits = vec![2, 3, 4, ahead - 1, ahead, ahead + 1]; + fits.sort(); + fits.dedup(); + for fit in fits { + for device in [true, false] { + let fake = Fake::default(); + let (mut app, cid) = setup_long(true, &fake, 8.0); + if !device { + app.hooks.audio_device = Some(Box::new(|_| None)); + } + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 30.0).build_eframe(|_| app); + h.state_mut().use_manual_clock(); + h.run_steps(2); + let budget = ((fit as f64 + 0.5) * 9216.0 / 0.9) as usize; + h.state().frames.set_budget(budget); + h.state().frames.clear(); + assert!(until_started(&mut h), "fit {fit} (ahead {ahead}), device {device}: the preview left its startup"); + assert_eq!(h.state().audio.is_some(), device, "fit {fit}, device {device}"); + if device { + assert_eq!(audio_frame(&h, cid), Some(0), "fit {fit}: from the first frame"); + } + } + } +} + +/// An audible 160×120 comp with a 3D layer and Draft 3D, Extended Viewer on: its viewer frames +/// render the pasteboard too, larger than the comp. Returns the harness once the first frame has +/// rendered, the comp, and that frame's size. +fn extended_viewer(fake: &Fake) -> (Harness<'static, EffectcraftApp>, ItemId, [usize; 2]) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "E", "width": 160, "height": 120, "frameRate": 30, "duration": 8})).unwrap(); + let cid = s.active_comp_id().unwrap(); + let l = s.execute("layer.newSolid", json!({"color": "#4060a0"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("effect.apply", json!({"layers": [l], "effect": "ec.audio.tone"})).unwrap(); + s.execute("layer.setSwitch", json!({"layers": [l], "switch": "audio", "value": true})).unwrap(); + s.execute("layer.setSwitch", json!({"layers": [l], "switch": "threeD", "value": true})).unwrap(); + s.execute("comp.setSwitch", json!({"switch": "draft3d", "value": true})).unwrap(); + s.prefs.three_d.extended_viewer = true; + let mut app = EffectcraftApp::new(s); + let f = fake.clone(); + app.hooks.audio_device = Some(Box::new(move |_| Some(Box::new(f.clone()) as Box))); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 30.0).build_eframe(|_| app); + h.state_mut().use_manual_clock(); + // The viewer shows the pasteboard: frames render the extended region. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + let size = loop { + step(&mut h); + let series = h.state().shown_series(cid); + if let Some(img) = h.state().frames.get(&series) { + break img.size(); + } + assert!(std::time::Instant::now() < deadline, "the first frame rendered"); + std::thread::sleep(std::time::Duration::from_millis(5)); }; + assert!(h.state().ui.viewer.extended.is_some(), "Extended Viewer is on"); + (h, cid, size) +} + +/// Extended Viewer frames render larger than the comp: a budget that would hold `ahead` comp-sized +/// frames holds fewer of them, and the start waits only for as many as really fit (#947). +#[test] +fn startup_finishes_when_extended_viewer_frames_are_larger() { + let fake = Fake::default(); + let (mut h, cid, size) = extended_viewer(&fake); + let ahead = (h.state().frames.parallelism() * 2).max(4); + let (comp_bytes, real_bytes) = (160 * 120 * 4, size[0] * size[1] * 4); + // Room for `ahead` comp-sized frames (and a half), which is fewer real ones. + let budget = ((ahead as f64 + 0.5) * comp_bytes as f64 / 0.9) as usize; + assert!((budget as f64 * 0.9 / real_bytes as f64) < ahead as f64, "real frames ({size:?}) fit fewer than {ahead}"); + h.state().frames.set_budget(budget); + h.state().frames.clear(); + let now = h.state().now(); + h.state_mut().play(now); + step(&mut h); + // The first frame renders before the harness clock moves on: each step is 1/30 s however long + // it takes, so on a busy machine the first frame could take longer than STARTUP_LIMIT of that + // clock, and this test is about the wait for the frames ahead. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + while !h.state().frames.is_cached(&h.state().shown_series(cid)) && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + while h.state().playback.starting && std::time::Instant::now() < deadline { + step(&mut h); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + assert!(!h.state().playback.starting, "the preview left its startup"); + assert!(h.state().audio.is_some(), "with its sound"); + assert_eq!(audio_frame(&h, cid), Some(0), "from the first frame"); + let (started, stopped) = { + let d = fake.0.lock().unwrap(); + (d.0.is_some(), d.1) + }; + assert!(started && !stopped, "the device started and kept playing"); +} + +/// A first frame that takes longer than STARTUP_WAIT to be cached (a heavy comp, a busy machine; +/// here it can't be cached until the RAM preview grows): the start keeps holding it, then the +/// sound starts from it and keeps playing. It used to give up two seconds after Play, start the +/// sound on the uncached frame and stop it at once, so the opening played silently. +#[test] +fn a_slow_first_frame_keeps_its_opening_sound() { + let fake = Fake::default(); + let (mut h, cid, size) = extended_viewer(&fake); + let (comp_bytes, real_bytes) = (160 * 120 * 4, size[0] * size[1] * 4); + assert!(real_bytes > comp_bytes * 2, "Extended Viewer frames ({size:?}) are larger than the comp"); + h.state().frames.set_budget((comp_bytes + real_bytes) / 2); + h.state().frames.clear(); let start = h.state().now(); h.state_mut().play(start); - assert!(h.state().playback.audio_held && h.state().audio.is_none(), "nothing is cached: no sound yet"); - let mut last = frame(&h); - let mut shown = 0; - // Two seconds of playback at one frame per clock step: keep the prefetch window cached, but - // not the rest of the 30 s comp, so the sound has to start by the real-time rule. - for step in 1..=60 { - let target = last + 1; - let window: Vec = (target..target + ahead).collect(); - gate.render(&mut h, cid, &window); - // Set the clock to this step's exact time rather than adding 1/30 repeatedly: accumulated - // f64 error can put one step just below a frame boundary and the next just above it, so - // `floor(elapsed × fps)` would otherwise jump from frame N to N + 2 for a one-frame step. - let now = h.state().now(); - let tick_time = start + step as f64 / 30.0; - h.state_mut().clock_mut().advance(tick_time - now); - h.step(); - if h.state().audio.is_some() { - break; - } - let f = frame(&h); - assert!(f == last || f == last + 1, "{last} → {f}: a frame was skipped"); - shown += usize::from(f != last); - last = f; + while h.state().now() - start < 3.0 { + step(&mut h); + assert!(h.state().playback.starting, "it still holds the first frame"); + assert_eq!(frame(&h, cid), 0); } - assert!(h.state().audio.is_some() && fake.0.lock().unwrap().0.is_some(), "the sound started within 2 s, at frame {last} of 900"); - assert!(shown >= 3, "the sound starts only after the silent preview kept up for a moment"); + assert!(fake.0.lock().unwrap().0.is_none(), "no sound yet"); + h.state().frames.set_budget(1 << 30); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + while h.state().playback.starting && std::time::Instant::now() < deadline { + step(&mut h); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + assert!(!h.state().playback.starting, "the preview left its startup"); + assert_eq!(audio_frame(&h, cid), Some(0), "with its sound from the first frame"); + let (started, stopped) = { + let d = fake.0.lock().unwrap(); + (d.0.is_some(), d.1) + }; + assert!(started && !stopped, "the device started and kept playing"); +} + +/// An output device whose start fails. +#[derive(Clone, Default)] +struct Broken; + +impl AudioDevice for Broken { + fn sample_rate(&self) -> u32 { + 48000 + } + fn start(&mut self, _feed: Arc) -> Result<(), String> { + Err("device unavailable".into()) + } + fn stop(&mut self) {} +} + +/// A device that fails to start: the picture plays on its own from the first frame. +#[test] +fn a_device_that_fails_to_start_plays_the_picture() { + let fake = Fake::default(); + let (mut app, cid) = setup_long(true, &fake, 8.0); + app.hooks.audio_device = Some(Box::new(|_| Some(Box::new(Broken) as Box))); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 30.0).build_eframe(|_| app); + h.state_mut().use_manual_clock(); + h.run_steps(2); + assert!(until_started(&mut h), "it left its startup"); + assert!(h.state().audio.is_none() && h.state().playback.playing); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + while frame(&h, cid) == 0 && std::time::Instant::now() < deadline { + step(&mut h); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(frame(&h, cid) > 0, "the picture plays"); +} + +/// An output that plays: a thread takes the feed's samples in real time, as a sound card would. +#[derive(Clone, Default)] +struct Playing(Arc>); + +impl AudioDevice for Playing { + fn sample_rate(&self) -> u32 { + 48000 + } + fn start(&mut self, feed: Arc) -> Result<(), String> { + let stop = self.0.clone(); + std::thread::spawn(move || { + let mut buf = vec![0.0f32; 480 * 2]; + while !*stop.lock().unwrap() { + feed.pull(&mut buf); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + }); + Ok(()) + } + fn stop(&mut self) { + *self.0.lock().unwrap() = true; + } +} + +/// Once started, the sound's clock moves the picture on from the first frame, and a frame that +/// isn't cached still stops the sound rather than being skipped. +#[test] +fn a_started_preview_follows_its_sound() { + let fake = Fake::default(); + let (mut app, cid) = setup_long(true, &fake, 8.0); + let device = Playing::default(); + let d = device.clone(); + app.hooks.audio_device = Some(Box::new(move |_| Some(Box::new(d.clone()) as Box))); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 30.0).build_eframe(|_| app); + h.state_mut().use_manual_clock(); + h.run_steps(2); + assert!(until_started(&mut h) && h.state().audio.is_some(), "the sound started"); + assert_eq!(audio_frame(&h, cid), Some(0), "from the first frame"); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + while frame(&h, cid) < 5 && std::time::Instant::now() < deadline && h.state().audio.is_some() { + step(&mut h); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert!(frame(&h, cid) >= 5, "the picture follows the sound's clock (at frame {})", frame(&h, cid)); + h.state().frames.set_budget(0); + h.state().frames.clear(); + step(&mut h); + assert!(h.state().audio.is_none() && h.state().playback.audio_held && h.state().playback.playing, "a stall stops the sound"); + *device.0.lock().unwrap() = true; +} + +/// A RAM preview that can't hold even one frame (none, or half of one): the start doesn't wait +/// for frames that can never be cached. The sound isn't started on a frame that can't be shown +/// (a stall would stop it at once): the preview goes on as a stalled one does. +#[test] +fn startup_does_not_wait_when_the_ram_preview_holds_no_frame() { + for budget in [0, 9216 / 2] { + let fake = Fake::default(); + let (app, _) = setup_long(true, &fake, 8.0); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 30.0).build_eframe(|_| app); + h.state_mut().use_manual_clock(); + h.run_steps(2); + h.state().frames.set_budget(budget); + h.state().frames.clear(); + let now = h.state().now(); + h.state_mut().play(now); + step(&mut h); + assert!(!h.state().playback.starting, "budget {budget}: no wait"); + assert!(fake.0.lock().unwrap().0.is_none(), "budget {budget}: no sound started and dropped"); + assert!(h.state().playback.playing && h.state().playback.audio_held, "budget {budget}: it goes on as a stalled preview"); + } +} + +/// A frame that never gets cached although the estimate says one fits (an Extended Viewer frame +/// larger than the whole budget): the start gives up after STARTUP_LIMIT instead of holding the +/// first frame for ever, without starting the sound on a frame it can't show. +#[test] +fn startup_gives_up_on_a_first_frame_that_never_caches() { + let fake = Fake::default(); + let (mut h, cid, size) = extended_viewer(&fake); + let (comp_bytes, real_bytes) = (160 * 120 * 4, size[0] * size[1] * 4); + assert!(real_bytes > comp_bytes * 2, "Extended Viewer frames ({size:?}) are larger than the comp"); + // Room for a comp-sized frame but not a real one: the current frame can never be cached. + let budget = (comp_bytes + real_bytes) / 2; + h.state().frames.set_budget(budget); + h.state().frames.clear(); + let start = h.state().now(); + h.state_mut().play(start); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + while h.state().playback.starting && std::time::Instant::now() < deadline { + assert_eq!(frame(&h, cid), 0, "it holds the first frame while it waits"); + step(&mut h); + } + let waited = h.state().now() - start; + assert!(!h.state().playback.starting, "it gave up waiting"); + assert!((9.9..12.0).contains(&waited), "after about ten seconds ({waited:.2} s)"); + assert!(fake.0.lock().unwrap().0.is_none(), "no sound started and dropped"); + assert!(h.state().playback.playing && h.state().playback.audio_held, "it goes on as a stalled preview"); } diff --git a/docs/gaps.md b/docs/gaps.md index 3f59b1f99..031ca3261 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -133,6 +133,12 @@ The most important missing piece: it turns every other estimate here into a meas the prompt, with their layers, in one undo step, for single items, multiple selections and folder contents. Nothing happens with an empty selection, in other panels, while typing or with a dialog open, and the next Delete still asks. +- Regression evidence for [#947](https://github.com/storytold/effectcraft/issues/947): the + preview startup tests in `ui_audio_scrub`, checked headlessly on Windows with a fake output + device, start an audible preview on an uncached comp. The playhead holds the first frame until + the frames ahead are cached, the sound starts at that frame (from 0 s and from 1 s, also for a + 30 s work area), and a tone lasting only the first 100 ms reaches the output. Video off still + starts at once, no device plays the picture, and Stop while preparing leaves nothing playing. - Regression evidence for [#612](https://github.com/storytold/effectcraft/issues/612): the `ui_gradient_value` and `ui_gradient_editor` input tests, checked headlessly on Windows, open the Gradient Editor from the Timeline's "Edit Gradient…" on a shape layer's Gradient Fill. They