From d383a1e8bb67aed08b3e4da1c0df0f6e994affe5 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:03:41 +0200 Subject: [PATCH 1/6] Make the audio preview follow mute changes made during playback (#2104) --- crates/ui-egui/src/audio.rs | 160 +++++++++++++++++++++++++++++++----- crates/ui-egui/src/lib.rs | 4 + 2 files changed, 142 insertions(+), 22 deletions(-) diff --git a/crates/ui-egui/src/audio.rs b/crates/ui-egui/src/audio.rs index 7972e6942..1ddff0c5b 100644 --- a/crates/ui-egui/src/audio.rs +++ b/crates/ui-egui/src/audio.rs @@ -23,7 +23,7 @@ use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::thread::JoinHandle; -use effectcraft_engine::project::ItemId; +use effectcraft_engine::project::{ItemId, Project}; use effectcraft_engine::time::{FrameRate, Tick}; use crate::frames::RenderSource; @@ -179,12 +179,24 @@ pub struct AudioPlayback { device: Box, stop: Arc, thread: Option>, - /// wasm32 (no threads): what the feeder thread would own, run by [`AudioPlayback::pump`]. - feeder: Option<(RenderSource, ItemId, i64, u32)>, + /// What the mixer reads and advances: the feeder thread, or [`AudioPlayback::pump`] on wasm32 + /// (no threads). + state: Arc>, + comp: ItemId, + mix: u32, pub rate: u32, pub span: PlaySpan, } +/// The mixer's position and its view of the project. +struct Feeder { + src: RenderSource, + /// The next comp sample index to mix. + cursor: i64, + /// Bumped when the project changes: a block mixed from an older project is dropped. + generation: u64, +} + /// Audio kept queued ahead of the device: ~200 ms (a feeder thread tops it up continuously); /// wasm32 tops it up once per UI frame, so it keeps ~350 ms to ride out slow frames. fn queue_target(rate: u32) -> usize { @@ -211,9 +223,10 @@ impl AudioPlayback { let span = PlaySpan::new(start, wa, wb, looping, rate); 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, feeder: Some((src, comp, span.start, mix)), rate, span }; + let mut p = AudioPlayback { feed: feed.clone(), device, stop, thread: None, state, comp, mix, rate, span }; p.device.start(feed)?; p.pump(); return Ok(p); @@ -223,15 +236,14 @@ impl AudioPlayback { // as that took (#408). The device plays silence until the first block arrives, and the // clock doesn't count it. let thread = { - let (feed, stop) = (feed.clone(), stop.clone()); + let (feed, stop, state) = (feed.clone(), stop.clone(), state.clone()); std::thread::Builder::new() .name("ec-audio-feed".into()) .spawn(move || { - let mut cursor = span.start; let target = queue_target(rate); while !stop.load(Ordering::Relaxed) { if feed.queued_frames() < target && !feed.finished.load(Ordering::Relaxed) { - cursor = feed_block(&src, comp, &feed, &span, cursor, rate, mix); + feed_block(&state, comp, &feed, &span, rate, mix); } else { std::thread::sleep(std::time::Duration::from_millis(4)); } @@ -240,21 +252,39 @@ impl AudioPlayback { .map_err(|e| e.to_string())? }; device.start(feed.clone())?; - Ok(AudioPlayback { feed, device, stop, thread: Some(thread), feeder: None, rate, span }) + Ok(AudioPlayback { feed, device, stop, thread: Some(thread), state, comp, mix, rate, span }) } /// Every UI frame while playing: on wasm32 mix ahead into the feed (what the feeder thread /// does elsewhere), then let the device take what it needs. pub fn pump(&mut self) { - if let Some((src, comp, cursor, mix)) = &mut self.feeder { + if cfg!(target_arch = "wasm32") { let target = queue_target(self.rate); while self.feed.queued_frames() < target && !self.feed.finished.load(Ordering::Relaxed) { - *cursor = feed_block(src, *comp, &self.feed, &self.span, *cursor, self.rate, *mix); + feed_block(&self.state, self.comp, &self.feed, &self.span, self.rate, self.mix); } } self.device.pump(); } + /// The project the sound is mixed from. + pub fn project(&self) -> Option> { + self.state.lock().ok().map(|s| s.src.project.clone()) + } + + /// Mix from `project` from now on (the project was edited while playing, e.g. a layer's Audio + /// switch, #2104): what is queued came from the old project, so it is dropped and mixing + /// resumes at the first sample the device has not taken yet (also when the feeder had + /// already mixed up to the end of a non-looping span). + pub fn set_project(&mut self, project: Arc) { + let Ok(mut s) = self.state.lock() else { return }; + s.src.project = project; + s.generation += 1; + self.feed.clear(); + self.feed.finished.store(false, Ordering::Relaxed); + s.cursor = clock_sample(&self.span, self.feed.consumed(), 0); + } + /// The comp sample index audible now. pub fn clock(&self) -> i64 { clock_sample(&self.span, self.feed.consumed(), self.device.latency_frames()) @@ -279,20 +309,33 @@ impl Drop for AudioPlayback { } } -/// Mix one block at `cursor` into the feed; returns the next cursor (wrapping or finishing at -/// the work-area end). -fn feed_block(src: &RenderSource, comp: ItemId, feed: &AudioFeed, span: &PlaySpan, cursor: i64, rate: u32, mix: u32) -> i64 { - let mut cursor = cursor; - if cursor >= span.wb { - if !span.looping { - feed.finished.store(true, Ordering::Relaxed); - return cursor; - } +/// 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) { + 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); + return; + }; + let mut block = None; + if cursor >= span.wb && span.looping { cursor = span.wa; } - let n = ((span.wb - cursor) as usize).min(BLOCK); - feed.push(&mix_block(src, comp, sample_time(cursor, rate), n, rate, mix)); - cursor + n as i64 + 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)); + cursor += n as i64; + } + let Ok(mut s) = state.lock() else { return }; + if s.generation != generation { + return; + } + match block { + Some(b) => feed.push(&b), + None => feed.finished.store(true, Ordering::Relaxed), + } + s.cursor = cursor; } /// `n` stereo frames of `comp`'s mix from comp time `t` at the device `rate`, mixed at `mix` Hz @@ -559,6 +602,79 @@ mod tests { playback.stop(); } + /// #2104: muting a layer while the preview plays silences it: the queued sound is dropped + /// and everything mixed afterwards comes from the edited project. + #[test] + fn muting_a_layer_while_playing_silences_the_preview() { + use effectcraft_engine::color::Label; + use effectcraft_engine::project::{Comp, Footage, FootageKind, ItemKind, LayerSource, build}; + use effectcraft_engine::render::FootageSource; + use effectcraft_engine::render::cache::LayerCache; + use std::time::{Duration, Instant}; + + /// Footage audio that is a constant 0.5. + struct Steady; + impl FootageSource for Steady { + fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { + None + } + fn audio(&self, _: ItemId, _: &Footage, _: Tick, frames: usize, _: u32) -> Option> { + Some(vec![0.5; frames * 2]) + } + } + struct Device; + impl AudioDevice for Device { + fn sample_rate(&self) -> u32 { + 48000 + } + fn start(&mut self, _: Arc) -> Result<(), String> { + Ok(()) + } + fn stop(&mut self) {} + } + + let mut p = Project::default(); + let footage = + Footage { path: "tone.flac".into(), kind: FootageKind::Audio, duration: Tick::from_seconds_f64(60.0), has_audio: true, ..Default::default() }; + let item = p.add_item("tone.flac", Label::SeaFoam, None, ItemKind::Footage(footage)); + let mut comp = Comp::new(64, 36, FrameRate::new(30, 1), Tick::from_seconds_f64(10.0)); + let layer = build::layer(&mut p, &comp, "tone.flac", LayerSource::Footage { item }, (0, 0), None); + comp.layers = vec![layer]; + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.into())); + let mut project = Arc::new(p); + let src = RenderSource { + project: project.clone(), + footage: Arc::new(Steady), + expr: None, + layer_cache: Arc::new(LayerCache::default()), + gpu: None, + gpu_display: false, + disk: None, + }; + let mut playback = + AudioPlayback::start(Box::new(Device), src, cid, Tick::ZERO, Tick::ZERO, Tick::from_seconds_f64(10.0), true, 0).expect("the preview starts"); + let t = Instant::now(); + while playback.feed.queued_frames() == 0 { + assert!(t.elapsed() < Duration::from_secs(30), "the feeder never delivered"); + std::thread::sleep(Duration::from_millis(10)); + } + // Mute the layer while the sound plays. + if let Some(c) = Arc::make_mut(&mut project).comp_mut(cid) { + c.layers[0].switches.audio = false; + } + playback.set_project(project.clone()); + assert!(playback.project().is_some_and(|p| Arc::ptr_eq(&p, &project))); + // The feeder tops the queue up again, now from the muted project. + while playback.feed.queued_frames() == 0 { + assert!(t.elapsed() < Duration::from_secs(30), "the feeder never delivered"); + std::thread::sleep(Duration::from_millis(10)); + } + let mut out = vec![1.0f32; playback.feed.queued_frames() * 2]; + playback.feed.pull(&mut out); + assert!(out.iter().all(|v| *v == 0.0), "the muted layer still plays"); + playback.stop(); + } + #[test] fn meter_decays_and_holds_peaks() { let mut m = Meter::default(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 72016cc53..e76788ef2 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1263,6 +1263,10 @@ impl EffectcraftApp { } } if let Some(a) = &mut self.audio { + // An edit while playing (mute, unmute, solo, levels): the sound follows it (#2104). + if a.project().is_some_and(|p| !Arc::ptr_eq(&p, &self.session.project)) { + a.set_project(self.session.project.clone()); + } a.pump(); self.meter.update(a.feed.take_peaks(), now); } else if let Some(s) = &mut self.scrub { From 2301fd6e84675996159a297172399b61eda003fe Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:09:16 +0200 Subject: [PATCH 2/6] Keep the audio queue when an edit during playback does not change the sound (#2104) --- crates/ui-egui/src/audio.rs | 90 ++++++++++++++++++++++++++++++++++++- crates/ui-egui/src/lib.rs | 2 +- 2 files changed, 90 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/audio.rs b/crates/ui-egui/src/audio.rs index 1ddff0c5b..029c7ca6b 100644 --- a/crates/ui-egui/src/audio.rs +++ b/crates/ui-egui/src/audio.rs @@ -23,7 +23,7 @@ use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::thread::JoinHandle; -use effectcraft_engine::project::{ItemId, Project}; +use effectcraft_engine::project::{ItemId, ItemKind, LayerSource, Project}; use effectcraft_engine::time::{FrameRate, Tick}; use crate::frames::RenderSource; @@ -285,6 +285,18 @@ impl AudioPlayback { s.cursor = clock_sample(&self.span, self.feed.consumed(), 0); } + /// Follow an edited `project`: mixing continues from it, and the queued sound is dropped only + /// when the edit changed what is heard ([`audio_changed`]); a transform or colour edit leaves + /// the queue alone, so playback does not drop out. + pub fn follow_project(&mut self, project: Arc) { + let Some(old) = self.project() else { return }; + if audio_changed(&old, &project, self.comp) { + self.set_project(project); + } else if let Ok(mut s) = self.state.lock() { + s.src.project = project; + } + } + /// The comp sample index audible now. pub fn clock(&self) -> i64 { clock_sample(&self.span, self.feed.consumed(), self.device.latency_frames()) @@ -309,6 +321,46 @@ impl Drop for AudioPlayback { } } +/// Whether `new` sounds different from `old` for `comp`: a layer's timing, source, Audio / Solo / +/// Effects switch, Audio Levels or effects, or the same for a nested comp. Other edits (video +/// properties, names, labels) do not change the mix. +fn audio_changed(old: &Project, new: &Project, comp: ItemId) -> bool { + fn comps_differ(old: &Project, new: &Project, comp: ItemId, depth: usize) -> bool { + let (Some(a), Some(b)) = (old.comp(comp), new.comp(comp)) else { return old.comp(comp).is_some() != new.comp(comp).is_some() }; + if depth >= 16 { + return false; + } + a.layers.len() != b.layers.len() + || a.layers.iter().zip(&b.layers).any(|(x, y)| { + x.id != y.id + || x.source != y.source + || x.start_time != y.start_time + || x.in_point != y.in_point + || x.out_point != y.out_point + || x.stretch != y.stretch + || x.switches.audio != y.switches.audio + || x.switches.solo != y.switches.solo + || x.switches.effects != y.switches.effects + || x.props.sub("audio") != y.props.sub("audio") + || x.effects() != y.effects() + || match &x.source { + LayerSource::Footage { item } => footage_differs(old, new, *item), + LayerSource::Comp { item } => comps_differ(old, new, *item, depth + 1), + _ => false, + } + }) + } + /// The footage item's audio availability changed (relinked, missing, no audio). + fn footage_differs(old: &Project, new: &Project, item: ItemId) -> bool { + let f = |p: &Project| match p.item(item).map(|i| &i.kind) { + Some(ItemKind::Footage(f)) => Some((f.has_audio, f.missing, f.path.clone())), + _ => None, + }; + f(old) != f(new) + } + comps_differ(old, new, comp, 0) +} + /// 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. @@ -675,6 +727,42 @@ mod tests { playback.stop(); } + /// #2104: only edits that change the mix drop the queued sound. + #[test] + fn only_audible_edits_change_the_audio() { + use effectcraft_engine::color::Label; + use effectcraft_engine::project::{Comp, Footage, FootageKind, build}; + + let mut p = Project::default(); + let footage = + Footage { path: "tone.flac".into(), kind: FootageKind::Audio, duration: Tick::from_seconds_f64(60.0), has_audio: true, ..Default::default() }; + let item = p.add_item("tone.flac", Label::SeaFoam, None, ItemKind::Footage(footage)); + let mut comp = Comp::new(64, 36, FrameRate::new(30, 1), Tick::from_seconds_f64(10.0)); + let layer = build::layer(&mut p, &comp, "tone.flac", LayerSource::Footage { item }, (0, 0), None); + comp.layers = vec![layer]; + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.into())); + let old = p; + // A video-only edit: the layer is hidden, its name and label change. + let mut video = old.clone(); + if let Some(l) = video.comp_mut(cid).and_then(|c| c.layers.first_mut()) { + l.switches.video = false; + l.name = "renamed".into(); + } + assert!(!audio_changed(&old, &video, cid), "a video edit must not drop the sound"); + // Muting, solo and a moved in point do change it. + for edit in 0..3 { + let mut edited = old.clone(); + if let Some(l) = edited.comp_mut(cid).and_then(|c| c.layers.first_mut()) { + match edit { + 0 => l.switches.audio = false, + 1 => l.switches.solo = true, + _ => l.in_point = Tick::from_seconds_f64(1.0), + } + } + assert!(audio_changed(&old, &edited, cid), "audible edit {edit} must be followed"); + } + } + #[test] fn meter_decays_and_holds_peaks() { let mut m = Meter::default(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e76788ef2..c3a17fa30 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1265,7 +1265,7 @@ impl EffectcraftApp { if let Some(a) = &mut self.audio { // An edit while playing (mute, unmute, solo, levels): the sound follows it (#2104). if a.project().is_some_and(|p| !Arc::ptr_eq(&p, &self.session.project)) { - a.set_project(self.session.project.clone()); + a.follow_project(self.session.project.clone()); } a.pump(); self.meter.update(a.feed.take_peaks(), now); From 55040de3d139f97a516c107cdb82ace687619ea2 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 1 Nov 2026 07:42:06 +0100 Subject: [PATCH 3/6] Build the #2104 audio tests' layer lists with into(), as Comp::layers is now a Layers (#2104) --- crates/ui-egui/src/audio.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/audio.rs b/crates/ui-egui/src/audio.rs index eb7828a18..f8b52fc88 100644 --- a/crates/ui-egui/src/audio.rs +++ b/crates/ui-egui/src/audio.rs @@ -691,7 +691,7 @@ mod tests { let item = p.add_item("tone.flac", Label::SeaFoam, None, ItemKind::Footage(footage)); let mut comp = Comp::new(64, 36, FrameRate::new(30, 1), Tick::from_seconds_f64(10.0)); let layer = build::layer(&mut p, &comp, "tone.flac", LayerSource::Footage { item }, (0, 0), None); - comp.layers = vec![layer]; + comp.layers = vec![layer].into(); let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.into())); let mut project = Arc::new(p); let src = RenderSource { @@ -739,7 +739,7 @@ mod tests { let item = p.add_item("tone.flac", Label::SeaFoam, None, ItemKind::Footage(footage)); let mut comp = Comp::new(64, 36, FrameRate::new(30, 1), Tick::from_seconds_f64(10.0)); let layer = build::layer(&mut p, &comp, "tone.flac", LayerSource::Footage { item }, (0, 0), None); - comp.layers = vec![layer]; + comp.layers = vec![layer].into(); let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.into())); let old = p; // A video-only edit: the layer is hidden, its name and label change. From 4966534c8274e2349dc75c3cd26e9200b69b01b0 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:55:33 +0100 Subject: [PATCH 4/6] Make the audio preview follow edits that change the sound only, and start it when a silent preview gains sound (#2104) --- crates/ui-egui/src/app/playback.rs | 1 + crates/ui-egui/src/app/prefetch.rs | 16 ++++++++++++ crates/ui-egui/src/audio.rs | 40 ++++++++++++++++++++++++++++-- crates/ui-egui/src/lib.rs | 2 ++ 4 files changed, 57 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/app/playback.rs b/crates/ui-egui/src/app/playback.rs index d40bc69ff..5bb860d5b 100644 --- a/crates/ui-egui/src/app/playback.rs +++ b/crates/ui-egui/src/app/playback.rs @@ -40,6 +40,7 @@ impl EffectcraftApp { restore_max, audio_frame: (!plan.video).then_some(plan.first), audio_held: false, + silent_project: None, starting: false, starting_since: now, first_cached: None, diff --git a/crates/ui-egui/src/app/prefetch.rs b/crates/ui-egui/src/app/prefetch.rs index c18bb2415..2277589de 100644 --- a/crates/ui-egui/src/app/prefetch.rs +++ b/crates/ui-egui/src/app/prefetch.rs @@ -70,6 +70,7 @@ impl EffectcraftApp { let step = plan.map_or(1, |p| p.step); let fit_ahead = self.frames_that_fit(&c, scale) - 1; self.prefetch_playback(&pass, fit_ahead); + self.wake_silent_preview(); self.pump_meters(ctx, now); if !self.playback.playing { self.cache_when_idle(ctx, &c, &series, now, input_active, (wa, wb)); @@ -254,6 +255,21 @@ impl EffectcraftApp { false } + /// A silent preview whose project was edited so that it has sound now (a layer unmuted while + /// playing, #2104): hold the sound for [`Self::start_held_audio`], which starts it from the + /// current frame once the frames ahead are cached. Checked once per project edit. + fn wake_silent_preview(&mut self) { + let pb = &self.playback; + if self.audio.is_some() || !pb.playing || pb.caching || pb.starting || pb.audio_held || !pb.plan.is_some_and(|p| p.video) { + return; + } + if pb.silent_project.as_ref().is_some_and(|p| std::sync::Arc::ptr_eq(p, &self.session.project)) { + return; + } + self.playback.silent_project = Some(self.session.project.clone()); + self.playback.audio_held = self.audio_plays(); + } + /// 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. diff --git a/crates/ui-egui/src/audio.rs b/crates/ui-egui/src/audio.rs index f8b52fc88..eb54822d1 100644 --- a/crates/ui-egui/src/audio.rs +++ b/crates/ui-egui/src/audio.rs @@ -23,7 +23,7 @@ use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::thread::JoinHandle; -use effectcraft_engine::project::{ItemId, ItemKind, LayerSource, Project}; +use effectcraft_engine::project::{GroupKind, ItemId, ItemKind, Layer, LayerSource, Project, PropGroup}; use effectcraft_engine::time::{FrameRate, Tick}; use crate::frames::RenderSource; @@ -342,7 +342,7 @@ fn audio_changed(old: &Project, new: &Project, comp: ItemId) -> bool { || x.switches.solo != y.switches.solo || x.switches.effects != y.switches.effects || x.props.sub("audio") != y.props.sub("audio") - || x.effects() != y.effects() + || audio_effects(x) != audio_effects(y) || match &x.source { LayerSource::Footage { item } => footage_differs(old, new, *item), LayerSource::Comp { item } => comps_differ(old, new, *item, depth + 1), @@ -350,6 +350,12 @@ fn audio_changed(old: &Project, new: &Project, comp: ItemId) -> bool { } }) } + /// The layer's audio effects (Effect > Audio), the only effects the mixer applies; a video effect + /// edit leaves them as they were. + fn audio_effects(l: &Layer) -> Vec<&PropGroup> { + let is_audio = |g: &&PropGroup| matches!(&g.kind, GroupKind::Effect { effect } if effectcraft_engine::effects::audio_fx::is_audio_effect(effect)); + l.effects().into_iter().flat_map(|fx| fx.groups()).filter(is_audio).collect() + } /// The footage item's audio availability changed (relinked, missing, no audio). fn footage_differs(old: &Project, new: &Project, item: ItemId) -> bool { let f = |p: &Project| match p.item(item).map(|i| &i.kind) { @@ -763,6 +769,36 @@ mod tests { } } + /// #2104: a video effect edit leaves the mix alone, an audio effect changes it. + #[test] + fn only_audio_effects_change_the_audio() { + use effectcraft_engine::color::Label; + use effectcraft_engine::project::build::{self, Ids}; + use effectcraft_engine::project::{Comp, Footage, FootageKind}; + + let mut p = Project::default(); + let footage = + Footage { path: "tone.flac".into(), kind: FootageKind::Audio, duration: Tick::from_seconds_f64(60.0), has_audio: true, ..Default::default() }; + let item = p.add_item("tone.flac", Label::SeaFoam, None, ItemKind::Footage(footage)); + let mut comp = Comp::new(64, 36, FrameRate::new(30, 1), Tick::from_seconds_f64(10.0)); + let layer = build::layer(&mut p, &comp, "tone.flac", LayerSource::Footage { item }, (0, 0), None); + comp.layers = vec![layer].into(); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.into())); + let with_effect = |id: &str| { + let mut edited = p.clone(); + let spec = effectcraft_engine::effects::find(id).expect("the effect exists"); + let mut next = edited.next_id; + let g = effectcraft_engine::effects::instantiate(spec, &mut Ids(&mut next), spec.name, [64.0, 36.0]); + edited.next_id = next; + if let Some(fx) = edited.comp_mut(cid).and_then(|c| c.layers.first_mut()).and_then(|l| l.props.sub_mut("effects")) { + fx.children.push(g.into()); + } + edited + }; + assert!(!audio_changed(&p, &with_effect("ec.blur.gaussian"), cid), "a video effect must not drop the sound"); + assert!(audio_changed(&p, &with_effect("ec.audio.backwards"), cid), "an audio effect must be followed"); + } + #[test] fn meter_decays_and_holds_peaks() { let mut m = Meter::default(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e237eeaa6..cd39b2ba7 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -196,6 +196,8 @@ 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, + /// The project last checked for a silent preview that became audible (#2104). + pub silent_project: Option>, /// 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 From 31da676415e2022c1cd417d4d3b395148742e01f Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Tue, 10 Nov 2026 13:10:10 +0100 Subject: [PATCH 5/6] #2104: bound audio comparisons across reused precomps --- crates/ui-egui/src/audio.rs | 18 +++++++++++------ crates/ui-egui/src/audio/live_tests.rs | 28 ++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/audio.rs b/crates/ui-egui/src/audio.rs index 015794305..a8cb54d6d 100644 --- a/crates/ui-egui/src/audio.rs +++ b/crates/ui-egui/src/audio.rs @@ -18,7 +18,7 @@ //! **Scrubbing:** Ctrl/Cmd-dragging the current-time indicator plays a short snippet of the mix //! at each new frame ([`AudioScrub`], `playback.scrubAudio`), as in After Effects. -use std::collections::VecDeque; +use std::collections::{HashSet, VecDeque}; use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::thread::JoinHandle; @@ -338,11 +338,17 @@ impl Drop for AudioPlayback { /// Effects switch, Audio Levels or effects, or the same for a nested comp. Other edits (video /// properties, names, labels) do not change the mix unless an audio expression can read them. fn audio_changed(old: &Project, new: &Project, comp: ItemId) -> bool { - fn comps_differ(old: &Project, new: &Project, comp: ItemId, depth: usize) -> bool { - let (Some(a), Some(b)) = (old.comp(comp), new.comp(comp)) else { return old.comp(comp).is_some() != new.comp(comp).is_some() }; - if depth >= 16 { + audio_changed_inner(old, new, comp, &mut HashSet::new()) +} + +/// Each shared precomp is compared once per depth. A depth limit alone still visits an +/// exponentially large number of paths in a small graph of reused precomps, blocking the UI. +fn audio_changed_inner(old: &Project, new: &Project, comp: ItemId, seen: &mut HashSet<(ItemId, usize)>) -> bool { + fn comps_differ(old: &Project, new: &Project, comp: ItemId, depth: usize, seen: &mut HashSet<(ItemId, usize)>) -> bool { + if depth >= 16 || !seen.insert((comp, depth)) { return false; } + let (Some(a), Some(b)) = (old.comp(comp), new.comp(comp)) else { return old.comp(comp).is_some() != new.comp(comp).is_some() }; a.layers.len() != b.layers.len() || a.layers.iter().zip(&b.layers).any(|(x, y)| { x.id != y.id @@ -360,7 +366,7 @@ fn audio_changed(old: &Project, new: &Project, comp: ItemId) -> bool { || audio_has_expression(y) || match &x.source { LayerSource::Footage { item } => footage_differs(old, new, *item), - LayerSource::Comp { item } => comps_differ(old, new, *item, depth + 1), + LayerSource::Comp { item } => comps_differ(old, new, *item, depth + 1, seen), _ => false, } }) @@ -386,7 +392,7 @@ fn audio_changed(old: &Project, new: &Project, comp: ItemId) -> bool { }; f(old) != f(new) } - comps_differ(old, new, comp, 0) + comps_differ(old, new, comp, 0, seen) } /// Mix one block at the feeder's cursor into the feed and move the cursor on (wrapping or diff --git a/crates/ui-egui/src/audio/live_tests.rs b/crates/ui-egui/src/audio/live_tests.rs index 6be2909cd..28ea3be3e 100644 --- a/crates/ui-egui/src/audio/live_tests.rs +++ b/crates/ui-egui/src/audio/live_tests.rs @@ -179,3 +179,31 @@ fn video_edits_keep_the_queue_unless_audio_expressions_can_read_them() { } } } + +#[test] +fn shared_nested_comps_are_compared_once_per_depth() { + let mut project = Project::default(); + let mut child = None; + for _ in 0..10 { + let mut comp = Comp::new(64, 36, FrameRate::new(30, 1), Tick::from_seconds_f64(1.0)); + if let Some(item) = child { + for _ in 0..10 { + let mut layer = build::layer(&mut project, &comp, "Nested", LayerSource::Comp { item }, (64, 36), None); + layer.switches.audio = false; + comp.layers.push(layer); + } + } + child = Some(project.add_item("Nested", Label::Sandstone, None, ItemKind::Comp(comp.into()))); + } + let root = child.unwrap(); + let mut edited = project.clone(); + edited.comp_mut(root).unwrap().layers[0].name = "Renamed".into(); + let mut seen = std::collections::HashSet::new(); + assert!(!super::audio_changed_inner(&project, &edited, root, &mut seen)); + assert_eq!(seen.len(), 10, "compare the ten comps, not a billion paths through them"); + let leaf = *seen.iter().find(|(_, depth)| *depth == 9).map(|(item, _)| item).unwrap(); + let mut layer = project.comp(root).unwrap().layers[0].clone(); + layer.source = LayerSource::Null; + edited.comp_mut(leaf).unwrap().layers.push(layer); + assert!(super::audio_changed(&project, &edited, root), "memoization must still find a nested edit"); +} From d047d89b9aa2f50ff8fd99fd1cafb0601e9ba385 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Tue, 10 Nov 2026 13:36:17 +0100 Subject: [PATCH 6/6] #2104: document audio comparison and regression contracts --- crates/ui-egui/src/audio.rs | 1 + crates/ui-egui/src/audio/live_tests.rs | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/crates/ui-egui/src/audio.rs b/crates/ui-egui/src/audio.rs index a8cb54d6d..960aa71e2 100644 --- a/crates/ui-egui/src/audio.rs +++ b/crates/ui-egui/src/audio.rs @@ -344,6 +344,7 @@ fn audio_changed(old: &Project, new: &Project, comp: ItemId) -> bool { /// Each shared precomp is compared once per depth. A depth limit alone still visits an /// exponentially large number of paths in a small graph of reused precomps, blocking the UI. fn audio_changed_inner(old: &Project, new: &Project, comp: ItemId, seen: &mut HashSet<(ItemId, usize)>) -> bool { + /// Compare one comp and its reachable audio sources, without revisiting a shared path depth. fn comps_differ(old: &Project, new: &Project, comp: ItemId, depth: usize, seen: &mut HashSet<(ItemId, usize)>) -> bool { if depth >= 16 || !seen.insert((comp, depth)) { return false; diff --git a/crates/ui-egui/src/audio/live_tests.rs b/crates/ui-egui/src/audio/live_tests.rs index 28ea3be3e..6ca45caac 100644 --- a/crates/ui-egui/src/audio/live_tests.rs +++ b/crates/ui-egui/src/audio/live_tests.rs @@ -34,6 +34,7 @@ impl FootageSource for Ramp { } } +/// Construct a paused feeder whose short nonzero span makes every source sample observable. fn playback(footage: Arc) -> AudioPlayback { let mut project = Project::default(); let f = Footage { path: "ramp.wav".into(), kind: FootageKind::Audio, has_audio: true, ..Default::default() }; @@ -64,16 +65,19 @@ fn playback(footage: Arc) -> AudioPlayback { } } +/// 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); } +/// Copy the active project and apply a layer edit through the same snapshot boundary as the UI. fn edit(p: &AudioPlayback, update: impl FnOnce(&mut Layer)) -> Arc { let mut project = p.project().unwrap(); update(&mut Arc::make_mut(&mut project).comp_mut(p.comp).unwrap().layers[0]); project } +/// Levels edits retain sample position across the loop boundary and the exclusive non-looping end. #[test] fn project_replacement_resumes_at_the_first_unconsumed_sample() { for looping in [false, true] { @@ -122,6 +126,7 @@ impl FootageSource for Gated { } } +/// A slow mix must not block edits or publish samples from the superseded project generation. #[test] fn a_block_in_flight_cannot_publish_after_a_mute() { let (entered, waiting) = mpsc::channel(); @@ -151,6 +156,7 @@ impl ExprHost for OpacityLevels { } } +/// Video properties are irrelevant to a plain mix, but may be dependencies of audio expressions. #[test] fn video_edits_keep_the_queue_unless_audio_expressions_can_read_them() { for expression in [false, true] { @@ -180,6 +186,7 @@ fn video_edits_keep_the_queue_unless_audio_expressions_can_read_them() { } } +/// Reused nested comps cost linear visits while edits to their descendants remain detectable. #[test] fn shared_nested_comps_are_compared_once_per_depth() { let mut project = Project::default();