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] 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