fix(audio): crossfade audio transitions through the clip handles (fixes #1473)

This commit is contained in:
Marco Arnulfo committed 2026-10-13 00:25:27 +02:00
1 parent 71438c627b
commit e22a49db1c
2 files changed
+70 -5

No files matched your search

+26 -5
View File
@@ -109,9 +109,11 @@ pub(crate) fn track_input_at(
if !tr.range().overlaps(&range) {
continue;
}
// Re-mix: recompute the covered samples with crossfade gains.
let from = tr.from.and_then(|id| track.item(id));
let to = tr.to.and_then(|id| track.item(id));
// Re-mix: recompute the covered samples with crossfade gains. Both sides play over the
// whole transition, into their handles as the picture does, so a transition centred on a
// cut overlaps the two clips instead of only fading each on its own side of the cut.
let from = tr.from.and_then(|id| track.item(id)).map(|i| with_handles(i, tr.start, tr.end()));
let to = tr.to.and_then(|id| track.item(id)).map(|i| with_handles(i, tr.start, tr.end()));
let s0 = tr.start.to_units_floor(sr as i64).max(start);
let s1 = tr.end().to_units_floor(sr as i64).min(start + frames as i64);
if s1 <= s0 {
@@ -124,10 +126,10 @@ pub(crate) fn track_input_at(
}
let mut a = AudioBuffer::silence(sr, w, n);
let mut b = AudioBuffer::silence(sr, w, n);
if let Some(f) = from {
if let Some(f) = &from {
mix_item(project, f, s0, n, sr, sources, &mut a, 1.0, &live);
}
if let Some(tt) = to {
if let Some(tt) = &to {
mix_item(project, tt, s0, n, sr, sources, &mut b, 1.0, &live);
}
let dur = (tr.end() - tr.start).to_units_floor(sr as i64).max(1) as f32;
@@ -143,6 +145,25 @@ pub(crate) fn track_input_at(
tbuf
}
/// `item` lengthened to cover `[t0, t1)` with its media timing unchanged: the part before its in
/// point and after its out point plays the source's handles (silent where the source has none).
/// Remixed clips have no linear media to extend and stay as they are.
fn with_handles(item: &TrackItem, t0: Tick, t1: Tick) -> TrackItem {
let mut ext = item.clone();
if crate::remix::is_remixed(item) {
return ext;
}
let head = (item.start - t0).max(Tick::ZERO);
let tail = (t1 - item.end()).max(Tick::ZERO);
ext.start = item.start - head;
ext.duration = item.duration + head + tail;
// forward media time is source_in + (t - start)·speed; reversed it counts down from the out
// point, so the extra head (forward) or tail (reverse) moves source_in back
let shift = if item.reverse { tail } else { head };
ext.source_in = item.source_in - Tick((shift.0 as f64 * item.speed.abs()).round() as i64);
ext
}
/// Held Clip Mixer controls of one track.
#[derive(Default)]
struct ClipLive {
+44
View File
@@ -7,6 +7,7 @@ use filmcraft_media::{AudioStreamInfo, FrameRequest, MediaError, MediaInfo, Medi
use filmcraft_project::mixer::{LANE_MUTE, LANE_PAN, LANE_VOLUME, send_lane};
use filmcraft_project::{
AudioChannels, AutomationMode, ItemId, ItemKind, Keyframe, Label, MediaClip, MediaRef, ParamValue, Project, SequenceSettings, TrackKind, TrackSend,
Transition, TransitionId,
};
use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick, TimeRange};
@@ -406,6 +407,49 @@ fn audio_transition_curves() {
}
}
/// A transition centred on a cut mixes both clips across its whole length: the outgoing clip plays
/// on past its out point and the incoming one starts before its in point (their handles), as the
/// picture does, so the two overlap instead of each just dipping at the cut. The incoming source is
/// a ramp, so its handle must come from the media just before its in point.
#[test]
fn audio_transition_on_a_cut_crossfades_through_the_handles() {
fn ramp(i: i64, _: usize) -> f32 {
i as f32 * 1e-6
}
let mut r = Rig::new(&[dc1, ramp]);
let (a_src, b_src) = (r.track(0).items[0].item, r.track(1).items[0].item);
let sec = |s: f64| Tick::from_seconds_f64(s);
let a = r.p.make_track_item(a_src, TrackKind::Audio, Tick::ZERO, TimeRange::new(Tick::ZERO, sec(1.0)), FrameRate::FPS_24).unwrap();
let b = r.p.make_track_item(b_src, TrackKind::Audio, sec(1.0), TimeRange::new(sec(2.0), sec(1.0)), FrameRate::FPS_24).unwrap();
let effect = filmcraft_project::vtransition::find_transition("constant_gain", filmcraft_project::EffectKind::AudioTransition).unwrap().instance();
let tr = Transition {
id: TransitionId(1),
effect,
start: sec(0.75),
duration: sec(0.5),
from: Some(a.id),
to: Some(b.id),
align: Default::default(),
reverse: false,
};
r.track(1).items.clear();
let t0 = r.track(0);
t0.items = vec![a, b];
t0.transitions.push(tr);
// the incoming clip plays media sample 96 000 at the cut (sample 48 000)
let b_at = |s: i64| ramp(s + 48_000, 0);
// constant gain at p = 0.25, 0.5 (the cut), 0.75: (1 - p)·A + p·B
for (s, p) in [(42_000, 0.25), (48_000, 0.5), (54_000, 0.75)] {
let want = (1.0 - p) * 0.1 + p * b_at(s);
let [l, rr] = r.mix(s, 1);
assert!(close(l[0], want, 1e-5) && close(rr[0], want, 1e-5), "sample {s}: {} (want {want})", l[0]);
}
// outside the transition each clip plays alone, at full level
for (s, want) in [(35_000, 0.1), (61_000, b_at(61_000))] {
assert!(close(r.mix(s, 1)[0][0], want, 1e-6), "sample {s}");
}
}
// ------------------------------------------------------------------------------------- 5.1
/// Distinct DC level per source channel: 0.1, 0.2, … (L, R, C, LFE, Ls, Rs for 6-channel sources).