diff --git a/src/Beutl.Engine/Animation/Easings/BackEaseIn.cs b/src/Beutl.Engine/Animation/Easings/BackEaseIn.cs index fc20cbedd2..488c07d214 100644 --- a/src/Beutl.Engine/Animation/Easings/BackEaseIn.cs +++ b/src/Beutl.Engine/Animation/Easings/BackEaseIn.cs @@ -2,6 +2,15 @@ public sealed class BackEaseIn : Easing { + public override bool TryGetOutputRange(out float minimum, out float maximum) + { + // p^3 - p*sin(pi*p), with p in [0,1]. Since p^3 - p is minimized at + // -2/(3*sqrt(3)) ~= -0.3849 and sin(pi*p) <= 1, [-0.39, 1] is conservative. + minimum = -0.39f; + maximum = 1; + return true; + } + public override float Ease(float progress) { return Funcs.BackEaseIn(progress); diff --git a/src/Beutl.Engine/Animation/Easings/BackEaseInOut.cs b/src/Beutl.Engine/Animation/Easings/BackEaseInOut.cs index cb53f8290f..083e2de45f 100644 --- a/src/Beutl.Engine/Animation/Easings/BackEaseInOut.cs +++ b/src/Beutl.Engine/Animation/Easings/BackEaseInOut.cs @@ -2,6 +2,13 @@ public sealed class BackEaseInOut : Easing { + public override bool TryGetOutputRange(out float minimum, out float maximum) + { + minimum = -0.195f; + maximum = 1.195f; + return true; + } + public override float Ease(float progress) { return Funcs.BackEaseInOut(progress); diff --git a/src/Beutl.Engine/Animation/Easings/BackEaseOut.cs b/src/Beutl.Engine/Animation/Easings/BackEaseOut.cs index 7ab4a8a499..0256dfd8f8 100644 --- a/src/Beutl.Engine/Animation/Easings/BackEaseOut.cs +++ b/src/Beutl.Engine/Animation/Easings/BackEaseOut.cs @@ -2,6 +2,13 @@ public sealed class BackEaseOut : Easing { + public override bool TryGetOutputRange(out float minimum, out float maximum) + { + minimum = 0; + maximum = 1.39f; + return true; + } + public override float Ease(float progress) { return Funcs.BackEaseOut(progress); diff --git a/src/Beutl.Engine/Animation/Easings/BounceEaseIn.cs b/src/Beutl.Engine/Animation/Easings/BounceEaseIn.cs index 03c0eaaf54..e8dc36dae0 100644 --- a/src/Beutl.Engine/Animation/Easings/BounceEaseIn.cs +++ b/src/Beutl.Engine/Animation/Easings/BounceEaseIn.cs @@ -1,6 +1,6 @@ namespace Beutl.Animation.Easings; -public sealed class BounceEaseIn : Easing +public sealed class BounceEaseIn : UnitRangeEasing { public override float Ease(float progress) { diff --git a/src/Beutl.Engine/Animation/Easings/BounceEaseInOut.cs b/src/Beutl.Engine/Animation/Easings/BounceEaseInOut.cs index 4abc77be3e..384bc5049c 100644 --- a/src/Beutl.Engine/Animation/Easings/BounceEaseInOut.cs +++ b/src/Beutl.Engine/Animation/Easings/BounceEaseInOut.cs @@ -1,6 +1,6 @@ namespace Beutl.Animation.Easings; -public sealed class BounceEaseInOut : Easing +public sealed class BounceEaseInOut : UnitRangeEasing { public override float Ease(float progress) { diff --git a/src/Beutl.Engine/Animation/Easings/BounceEaseOut.cs b/src/Beutl.Engine/Animation/Easings/BounceEaseOut.cs index 044fb26c5b..5c308f45e7 100644 --- a/src/Beutl.Engine/Animation/Easings/BounceEaseOut.cs +++ b/src/Beutl.Engine/Animation/Easings/BounceEaseOut.cs @@ -1,6 +1,6 @@ namespace Beutl.Animation.Easings; -public sealed class BounceEaseOut : Easing +public sealed class BounceEaseOut : UnitRangeEasing { public override float Ease(float progress) { diff --git a/src/Beutl.Engine/Animation/Easings/CircularEaseIn.cs b/src/Beutl.Engine/Animation/Easings/CircularEaseIn.cs index 2ca29023e5..6d6f85ca79 100644 --- a/src/Beutl.Engine/Animation/Easings/CircularEaseIn.cs +++ b/src/Beutl.Engine/Animation/Easings/CircularEaseIn.cs @@ -1,6 +1,6 @@ namespace Beutl.Animation.Easings; -public sealed class CircularEaseIn : Easing +public sealed class CircularEaseIn : UnitRangeEasing { public override float Ease(float progress) { diff --git a/src/Beutl.Engine/Animation/Easings/CircularEaseInOut.cs b/src/Beutl.Engine/Animation/Easings/CircularEaseInOut.cs index 2f3927840e..d1936d198c 100644 --- a/src/Beutl.Engine/Animation/Easings/CircularEaseInOut.cs +++ b/src/Beutl.Engine/Animation/Easings/CircularEaseInOut.cs @@ -1,6 +1,6 @@ namespace Beutl.Animation.Easings; -public sealed class CircularEaseInOut : Easing +public sealed class CircularEaseInOut : UnitRangeEasing { public override float Ease(float progress) { diff --git a/src/Beutl.Engine/Animation/Easings/CircularEaseOut.cs b/src/Beutl.Engine/Animation/Easings/CircularEaseOut.cs index 25ba399b1f..8057efdc1f 100644 --- a/src/Beutl.Engine/Animation/Easings/CircularEaseOut.cs +++ b/src/Beutl.Engine/Animation/Easings/CircularEaseOut.cs @@ -1,6 +1,6 @@ namespace Beutl.Animation.Easings; -public sealed class CircularEaseOut : Easing +public sealed class CircularEaseOut : UnitRangeEasing { public override float Ease(float progress) { diff --git a/src/Beutl.Engine/Animation/Easings/CubicEaseIn.cs b/src/Beutl.Engine/Animation/Easings/CubicEaseIn.cs index b2230c9925..b8c53024c1 100644 --- a/src/Beutl.Engine/Animation/Easings/CubicEaseIn.cs +++ b/src/Beutl.Engine/Animation/Easings/CubicEaseIn.cs @@ -1,6 +1,6 @@ namespace Beutl.Animation.Easings; -public sealed class CubicEaseIn : Easing +public sealed class CubicEaseIn : UnitRangeEasing { public override float Ease(float progress) { diff --git a/src/Beutl.Engine/Animation/Easings/CubicEaseInOut.cs b/src/Beutl.Engine/Animation/Easings/CubicEaseInOut.cs index 6e91018ce0..2c93a8e0c6 100644 --- a/src/Beutl.Engine/Animation/Easings/CubicEaseInOut.cs +++ b/src/Beutl.Engine/Animation/Easings/CubicEaseInOut.cs @@ -1,6 +1,6 @@ namespace Beutl.Animation.Easings; -public sealed class CubicEaseInOut : Easing +public sealed class CubicEaseInOut : UnitRangeEasing { public override float Ease(float progress) { diff --git a/src/Beutl.Engine/Animation/Easings/CubicEaseOut.cs b/src/Beutl.Engine/Animation/Easings/CubicEaseOut.cs index 8e2427ba48..d040835681 100644 --- a/src/Beutl.Engine/Animation/Easings/CubicEaseOut.cs +++ b/src/Beutl.Engine/Animation/Easings/CubicEaseOut.cs @@ -1,6 +1,6 @@ namespace Beutl.Animation.Easings; -public sealed class CubicEaseOut : Easing +public sealed class CubicEaseOut : UnitRangeEasing { public override float Ease(float progress) { diff --git a/src/Beutl.Engine/Animation/Easings/Easing.cs b/src/Beutl.Engine/Animation/Easings/Easing.cs index 1dcfb7021d..bc6ae3963d 100644 --- a/src/Beutl.Engine/Animation/Easings/Easing.cs +++ b/src/Beutl.Engine/Animation/Easings/Easing.cs @@ -3,4 +3,19 @@ public abstract class Easing { public abstract float Ease(float progress); + + /// + /// Tries to provide conservative bounds for every value can return when + /// progress is in the inclusive range [0, 1]. + /// + /// + /// Custom easings should override this when they can prove finite bounds. Consumers must treat + /// as unbounded rather than estimating the range by sampling. + /// + public virtual bool TryGetOutputRange(out float minimum, out float maximum) + { + minimum = default; + maximum = default; + return false; + } } diff --git a/src/Beutl.Engine/Animation/Easings/ElasticEaseIn.cs b/src/Beutl.Engine/Animation/Easings/ElasticEaseIn.cs index 7a1ff23b99..b807132950 100644 --- a/src/Beutl.Engine/Animation/Easings/ElasticEaseIn.cs +++ b/src/Beutl.Engine/Animation/Easings/ElasticEaseIn.cs @@ -2,6 +2,13 @@ public sealed class ElasticEaseIn : Easing { + public override bool TryGetOutputRange(out float minimum, out float maximum) + { + minimum = -1; + maximum = 1; + return true; + } + public override float Ease(float progress) { return Funcs.ElasticEaseIn(progress); diff --git a/src/Beutl.Engine/Animation/Easings/ElasticEaseInOut.cs b/src/Beutl.Engine/Animation/Easings/ElasticEaseInOut.cs index e2129b957c..1ff440eb67 100644 --- a/src/Beutl.Engine/Animation/Easings/ElasticEaseInOut.cs +++ b/src/Beutl.Engine/Animation/Easings/ElasticEaseInOut.cs @@ -2,6 +2,13 @@ public sealed class ElasticEaseInOut : Easing { + public override bool TryGetOutputRange(out float minimum, out float maximum) + { + minimum = -0.5f; + maximum = 1.5f; + return true; + } + public override float Ease(float progress) { return Funcs.ElasticEaseInOut(progress); diff --git a/src/Beutl.Engine/Animation/Easings/ElasticEaseOut.cs b/src/Beutl.Engine/Animation/Easings/ElasticEaseOut.cs index a489bbc29f..910963bef8 100644 --- a/src/Beutl.Engine/Animation/Easings/ElasticEaseOut.cs +++ b/src/Beutl.Engine/Animation/Easings/ElasticEaseOut.cs @@ -2,6 +2,13 @@ public sealed class ElasticEaseOut : Easing { + public override bool TryGetOutputRange(out float minimum, out float maximum) + { + minimum = 0; + maximum = 2; + return true; + } + public override float Ease(float progress) { return Funcs.ElasticEaseOut(progress); diff --git a/src/Beutl.Engine/Animation/Easings/ExponentialEaseIn.cs b/src/Beutl.Engine/Animation/Easings/ExponentialEaseIn.cs index 869891339e..2fc96f103b 100644 --- a/src/Beutl.Engine/Animation/Easings/ExponentialEaseIn.cs +++ b/src/Beutl.Engine/Animation/Easings/ExponentialEaseIn.cs @@ -1,6 +1,6 @@ namespace Beutl.Animation.Easings; -public sealed class ExponentialEaseIn : Easing +public sealed class ExponentialEaseIn : UnitRangeEasing { public override float Ease(float progress) { diff --git a/src/Beutl.Engine/Animation/Easings/ExponentialEaseInOut.cs b/src/Beutl.Engine/Animation/Easings/ExponentialEaseInOut.cs index 90c633ff1b..751bec34aa 100644 --- a/src/Beutl.Engine/Animation/Easings/ExponentialEaseInOut.cs +++ b/src/Beutl.Engine/Animation/Easings/ExponentialEaseInOut.cs @@ -1,6 +1,6 @@ namespace Beutl.Animation.Easings; -public sealed class ExponentialEaseInOut : Easing +public sealed class ExponentialEaseInOut : UnitRangeEasing { public override float Ease(float progress) { diff --git a/src/Beutl.Engine/Animation/Easings/ExponentialEaseOut.cs b/src/Beutl.Engine/Animation/Easings/ExponentialEaseOut.cs index e05c908262..d074f7aa4e 100644 --- a/src/Beutl.Engine/Animation/Easings/ExponentialEaseOut.cs +++ b/src/Beutl.Engine/Animation/Easings/ExponentialEaseOut.cs @@ -1,6 +1,6 @@ namespace Beutl.Animation.Easings; -public sealed class ExponentialEaseOut : Easing +public sealed class ExponentialEaseOut : UnitRangeEasing { public override float Ease(float progress) { diff --git a/src/Beutl.Engine/Animation/Easings/HoldEasing.cs b/src/Beutl.Engine/Animation/Easings/HoldEasing.cs index 160383ad1f..7a1ad9cf79 100644 --- a/src/Beutl.Engine/Animation/Easings/HoldEasing.cs +++ b/src/Beutl.Engine/Animation/Easings/HoldEasing.cs @@ -1,6 +1,6 @@ namespace Beutl.Animation.Easings; -public sealed class HoldEasing : Easing +public sealed class HoldEasing : UnitRangeEasing { public override float Ease(float progress) { diff --git a/src/Beutl.Engine/Animation/Easings/LinearEasing.cs b/src/Beutl.Engine/Animation/Easings/LinearEasing.cs index 0b1de68662..0b4f302cdf 100644 --- a/src/Beutl.Engine/Animation/Easings/LinearEasing.cs +++ b/src/Beutl.Engine/Animation/Easings/LinearEasing.cs @@ -1,6 +1,6 @@ namespace Beutl.Animation.Easings; -public sealed class LinearEasing : Easing +public sealed class LinearEasing : UnitRangeEasing { public override float Ease(float progress) { diff --git a/src/Beutl.Engine/Animation/Easings/QuadraticEaseIn.cs b/src/Beutl.Engine/Animation/Easings/QuadraticEaseIn.cs index 8b19751b6e..e1fdf8df5e 100644 --- a/src/Beutl.Engine/Animation/Easings/QuadraticEaseIn.cs +++ b/src/Beutl.Engine/Animation/Easings/QuadraticEaseIn.cs @@ -1,6 +1,6 @@ namespace Beutl.Animation.Easings; -public sealed class QuadraticEaseIn : Easing +public sealed class QuadraticEaseIn : UnitRangeEasing { public override float Ease(float progress) { diff --git a/src/Beutl.Engine/Animation/Easings/QuadraticEaseInOut.cs b/src/Beutl.Engine/Animation/Easings/QuadraticEaseInOut.cs index 0e0e2c0030..6e0b11d0d1 100644 --- a/src/Beutl.Engine/Animation/Easings/QuadraticEaseInOut.cs +++ b/src/Beutl.Engine/Animation/Easings/QuadraticEaseInOut.cs @@ -1,6 +1,6 @@ namespace Beutl.Animation.Easings; -public sealed class QuadraticEaseInOut : Easing +public sealed class QuadraticEaseInOut : UnitRangeEasing { public override float Ease(float progress) { diff --git a/src/Beutl.Engine/Animation/Easings/QuadraticEaseOut.cs b/src/Beutl.Engine/Animation/Easings/QuadraticEaseOut.cs index 2b89463b1e..d7572a2190 100644 --- a/src/Beutl.Engine/Animation/Easings/QuadraticEaseOut.cs +++ b/src/Beutl.Engine/Animation/Easings/QuadraticEaseOut.cs @@ -1,6 +1,6 @@ namespace Beutl.Animation.Easings; -public sealed class QuadraticEaseOut : Easing +public sealed class QuadraticEaseOut : UnitRangeEasing { public override float Ease(float progress) { diff --git a/src/Beutl.Engine/Animation/Easings/QuarticEaseIn.cs b/src/Beutl.Engine/Animation/Easings/QuarticEaseIn.cs index 17b810e803..1cd98a39cc 100644 --- a/src/Beutl.Engine/Animation/Easings/QuarticEaseIn.cs +++ b/src/Beutl.Engine/Animation/Easings/QuarticEaseIn.cs @@ -1,6 +1,6 @@ namespace Beutl.Animation.Easings; -public sealed class QuarticEaseIn : Easing +public sealed class QuarticEaseIn : UnitRangeEasing { public override float Ease(float progress) { diff --git a/src/Beutl.Engine/Animation/Easings/QuarticEaseInOut.cs b/src/Beutl.Engine/Animation/Easings/QuarticEaseInOut.cs index 8d1b323c39..d765397e4e 100644 --- a/src/Beutl.Engine/Animation/Easings/QuarticEaseInOut.cs +++ b/src/Beutl.Engine/Animation/Easings/QuarticEaseInOut.cs @@ -1,6 +1,6 @@ namespace Beutl.Animation.Easings; -public sealed class QuarticEaseInOut : Easing +public sealed class QuarticEaseInOut : UnitRangeEasing { public override float Ease(float progress) { diff --git a/src/Beutl.Engine/Animation/Easings/QuarticEaseOut.cs b/src/Beutl.Engine/Animation/Easings/QuarticEaseOut.cs index fbfbce8eb7..5419a52aaa 100644 --- a/src/Beutl.Engine/Animation/Easings/QuarticEaseOut.cs +++ b/src/Beutl.Engine/Animation/Easings/QuarticEaseOut.cs @@ -1,6 +1,6 @@ namespace Beutl.Animation.Easings; -public sealed class QuarticEaseOut : Easing +public sealed class QuarticEaseOut : UnitRangeEasing { public override float Ease(float progress) { diff --git a/src/Beutl.Engine/Animation/Easings/QuinticEaseIn.cs b/src/Beutl.Engine/Animation/Easings/QuinticEaseIn.cs index 0b1c69014e..c08ae4e298 100644 --- a/src/Beutl.Engine/Animation/Easings/QuinticEaseIn.cs +++ b/src/Beutl.Engine/Animation/Easings/QuinticEaseIn.cs @@ -1,6 +1,6 @@ namespace Beutl.Animation.Easings; -public sealed class QuinticEaseIn : Easing +public sealed class QuinticEaseIn : UnitRangeEasing { public override float Ease(float progress) { diff --git a/src/Beutl.Engine/Animation/Easings/QuinticEaseInOut.cs b/src/Beutl.Engine/Animation/Easings/QuinticEaseInOut.cs index 6944bad9d7..0a3bc93f2c 100644 --- a/src/Beutl.Engine/Animation/Easings/QuinticEaseInOut.cs +++ b/src/Beutl.Engine/Animation/Easings/QuinticEaseInOut.cs @@ -1,6 +1,6 @@ namespace Beutl.Animation.Easings; -public sealed class QuinticEaseInOut : Easing +public sealed class QuinticEaseInOut : UnitRangeEasing { public override float Ease(float progress) { diff --git a/src/Beutl.Engine/Animation/Easings/QuinticEaseOut.cs b/src/Beutl.Engine/Animation/Easings/QuinticEaseOut.cs index 8e3080fbff..ea402e73fe 100644 --- a/src/Beutl.Engine/Animation/Easings/QuinticEaseOut.cs +++ b/src/Beutl.Engine/Animation/Easings/QuinticEaseOut.cs @@ -1,6 +1,6 @@ namespace Beutl.Animation.Easings; -public sealed class QuinticEaseOut : Easing +public sealed class QuinticEaseOut : UnitRangeEasing { public override float Ease(float progress) { diff --git a/src/Beutl.Engine/Animation/Easings/SineEaseIn.cs b/src/Beutl.Engine/Animation/Easings/SineEaseIn.cs index ee8d04ea45..40d307961b 100644 --- a/src/Beutl.Engine/Animation/Easings/SineEaseIn.cs +++ b/src/Beutl.Engine/Animation/Easings/SineEaseIn.cs @@ -1,6 +1,6 @@ namespace Beutl.Animation.Easings; -public sealed class SineEaseIn : Easing +public sealed class SineEaseIn : UnitRangeEasing { public override float Ease(float progress) { diff --git a/src/Beutl.Engine/Animation/Easings/SineEaseInOut.cs b/src/Beutl.Engine/Animation/Easings/SineEaseInOut.cs index e388f6d323..f25f7ccc2e 100644 --- a/src/Beutl.Engine/Animation/Easings/SineEaseInOut.cs +++ b/src/Beutl.Engine/Animation/Easings/SineEaseInOut.cs @@ -1,6 +1,6 @@ namespace Beutl.Animation.Easings; -public sealed class SineEaseInOut : Easing +public sealed class SineEaseInOut : UnitRangeEasing { public override float Ease(float progress) { diff --git a/src/Beutl.Engine/Animation/Easings/SineEaseOut.cs b/src/Beutl.Engine/Animation/Easings/SineEaseOut.cs index 1e1b159603..c4c311e817 100644 --- a/src/Beutl.Engine/Animation/Easings/SineEaseOut.cs +++ b/src/Beutl.Engine/Animation/Easings/SineEaseOut.cs @@ -1,6 +1,6 @@ namespace Beutl.Animation.Easings; -public sealed class SineEaseOut : Easing +public sealed class SineEaseOut : UnitRangeEasing { public override float Ease(float progress) { diff --git a/src/Beutl.Engine/Animation/Easings/SplineEasing.cs b/src/Beutl.Engine/Animation/Easings/SplineEasing.cs index cf56f3da20..e502ce2e3b 100644 --- a/src/Beutl.Engine/Animation/Easings/SplineEasing.cs +++ b/src/Beutl.Engine/Animation/Easings/SplineEasing.cs @@ -66,6 +66,14 @@ public float Y2 private readonly KeySpline _internalKeySpline; + public override bool TryGetOutputRange(out float minimum, out float maximum) + { + // A cubic Bézier curve stays inside the convex hull of its control points. + minimum = Math.Min(0, Math.Min(Y1, Math.Min(Y2, 1))); + maximum = Math.Max(0, Math.Max(Y1, Math.Max(Y2, 1))); + return float.IsFinite(minimum) && float.IsFinite(maximum); + } + public override float Ease(float progress) { return _internalKeySpline.GetSplineProgress(progress); diff --git a/src/Beutl.Engine/Animation/Easings/UnitRangeEasing.cs b/src/Beutl.Engine/Animation/Easings/UnitRangeEasing.cs new file mode 100644 index 0000000000..a79a8b1125 --- /dev/null +++ b/src/Beutl.Engine/Animation/Easings/UnitRangeEasing.cs @@ -0,0 +1,12 @@ +namespace Beutl.Animation.Easings; + +/// An easing whose output is guaranteed to stay in [0, 1] for progress in [0, 1]. +public abstract class UnitRangeEasing : Easing +{ + public sealed override bool TryGetOutputRange(out float minimum, out float maximum) + { + minimum = 0; + maximum = 1; + return true; + } +} diff --git a/src/Beutl.Engine/Audio/Composing/Composer.cs b/src/Beutl.Engine/Audio/Composing/Composer.cs index d37c73abb2..fc3370ff70 100644 --- a/src/Beutl.Engine/Audio/Composing/Composer.cs +++ b/src/Beutl.Engine/Audio/Composing/Composer.cs @@ -12,6 +12,13 @@ public class Composer : IComposer private readonly ConditionalWeakTable _audioCache = []; private readonly List _currentEntry = new(); + // The entries active in the previous Compose window and that window's range. A sound whose captured + // range ended at the boundary still holds its latency tail, which the next window flushes (a sound + // ending exactly on the boundary cannot self-recover — its terminal clip window is full, so + // ClipNode.AppendFlushedTail has no room). An entry absent for any other reason is discarded. + private readonly List _previousEntry = new(); + private TimeRange? _previousRange; + private sealed class AudioNodeEntry : IDisposable { public List Nodes { get; set; } = new(); @@ -19,6 +26,8 @@ private sealed class AudioNodeEntry : IDisposable public bool IsDirty { get; set; } = true; public int Version { get; set; } public EventHandler? EditedHandler { get; set; } + public TimeRange SoundRange { get; set; } + public required Sound Sound { get; init; } public void Dispose() { @@ -69,6 +78,9 @@ public void Dispose() try { IsAudioRendering = true; + CompositionEligibility eligibility = frame.Eligibility + ?? throw new InvalidOperationException( + "Audio composition requires an eligibility snapshot."); _currentEntry.Clear(); foreach (var resource in frame.Objects) @@ -78,7 +90,14 @@ public void Dispose() } // Build final audio graph - return BuildFinalOutput(timeRange); + var result = BuildFinalOutput(timeRange, eligibility); + + // Record this window's active set so the next window can flush sounds that just ended. + _previousEntry.Clear(); + _previousEntry.AddRange(_currentEntry); + _previousRange = timeRange; + + return result; } finally { @@ -91,7 +110,7 @@ public void Dispose() } } - private AudioBuffer? BuildFinalOutput(TimeRange range) + private AudioBuffer? BuildFinalOutput(TimeRange range, CompositionEligibility eligibility) { // Multiple contexts - need to mix var buffers = new List(); @@ -109,6 +128,9 @@ public void Dispose() } } + // Recover the latency tail of any sound that ended on the previous window boundary. + AppendEndedSoundTails(range, eligibility, buffers); + // Mix all buffers mixedBuffer = MixBuffers(buffers); @@ -139,6 +161,53 @@ public void Dispose() } } + // Flushes the residual latency tail of every sound that was active last window but not this one, so + // a lookahead limiter's held samples land at the start of the window that follows the clip end (the + // tail belongs at [windowStart, windowStart + latency)). The drain produces a window-length buffer — + // tail at the front, silence after — so it mixes like any other branch. + private void AppendEndedSoundTails( + TimeRange range, + CompositionEligibility eligibility, + List buffers) + { + // Only when this window continues sequentially from the previous one. After a seek/restart the + // cached graph no longer abuts the new window (the limiter resets on the discontinuity anyway), + // so flushing it would inject a stale tail at the wrong time. + if (_previousRange is not { } previous || !IsContiguous(previous.End, range.Start)) + return; + + foreach (var entry in _previousEntry) + { + if (_currentEntry.Contains(entry)) + continue; + if (entry.IsDirty) + continue; + if (entry.OutputNodes is not { } outputNodes) + continue; + if (!IsSameTimestamp(entry.SoundRange.End, previous.End)) + continue; + if (entry.Sound.TimeRange != entry.SoundRange) + continue; + if (!eligibility.Contains(entry.Sound)) + continue; + + foreach (var outputNode in outputNodes) + { + if (outputNode.GetTotalLatencySamples(SampleRate) <= 0) + continue; + + var flushContext = new AudioProcessContext(range, SampleRate, _animationSampler, range); + buffers.Add(outputNode.Flush(flushContext)); + } + } + } + + private static bool IsContiguous(TimeSpan previousEnd, TimeSpan nextStart) + => Math.Abs((nextStart - previousEnd).Ticks) <= TimeSpan.TicksPerMillisecond; + + private static bool IsSameTimestamp(TimeSpan first, TimeSpan second) + => Math.Abs((first - second).Ticks) <= 1; + private AudioBuffer? MixBuffers(List buffers) { if (buffers.Count == 0) @@ -200,6 +269,11 @@ public void InvalidateCache() } _audioCache.Clear(); + + // The recorded entries were just disposed; drop them so the next window does not flush a freed + // graph (and treats the post-invalidate window as a fresh, non-contiguous start). + _previousEntry.Clear(); + _previousRange = null; } /// @@ -211,7 +285,7 @@ protected void ComposeSound(Sound.Resource resource, TimeRange timeRange) // Get or create cache entry if (!_audioCache.TryGetValue(sound, out var entry)) { - entry = new AudioNodeEntry(); + entry = new AudioNodeEntry { Sound = sound }; _audioCache.AddOrUpdate(sound, entry); // Register invalidation handler @@ -245,6 +319,7 @@ protected void ComposeSound(Sound.Resource resource, TimeRange timeRange) entry.IsDirty = false; } + entry.SoundRange = sound.TimeRange; _currentEntry.Add(entry); } diff --git a/src/Beutl.Engine/Audio/Effects/AudioEffect.cs b/src/Beutl.Engine/Audio/Effects/AudioEffect.cs index 7f9476a450..e867926dfb 100644 --- a/src/Beutl.Engine/Audio/Effects/AudioEffect.cs +++ b/src/Beutl.Engine/Audio/Effects/AudioEffect.cs @@ -10,4 +10,24 @@ public sealed partial class FallbackAudioEffect : AudioEffect, IFallback; public abstract partial class AudioEffect : EngineObject { public abstract AudioNode CreateNode(AudioContext context, AudioNode inputNode); + + /// + /// Reports the latency this effect introduces at , in samples, so a + /// host can query it without building a graph node. Report-only; the default 0 covers effects with + /// no plugin delay. Pass the output (post-resample) sample rate. + /// + /// + /// An override must agree with the of the node its + /// produces, and should return 0 when IsEnabled is false — matching + /// how Sound.Compose skips a disabled effect's , so the pre-graph + /// report matches the graph that gets built. Valid results are in the inclusive range + /// 0..; denotes an unbounded or saturated + /// latency budget. + /// + /// is not positive. + public virtual int GetLatencySamples(int sampleRate) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(sampleRate); + return 0; + } } diff --git a/src/Beutl.Engine/Audio/Effects/AudioEffectGroup.cs b/src/Beutl.Engine/Audio/Effects/AudioEffectGroup.cs index e6d2da0e1d..2b93516a53 100644 --- a/src/Beutl.Engine/Audio/Effects/AudioEffectGroup.cs +++ b/src/Beutl.Engine/Audio/Effects/AudioEffectGroup.cs @@ -20,4 +20,36 @@ public override AudioNode CreateNode(AudioContext context, AudioNode inputNode) return Children.Where(item => item.IsEnabled) .Aggregate(inputNode, (current, item) => item.CreateNode(context, current)); } + + // Enabled children run as a serial cascade (CreateNode), so latencies add. A disabled group reports 0 + // to match Sound.Compose skipping its CreateNode, keeping the report aligned with the built graph. + public override int GetLatencySamples(int sampleRate) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(sampleRate); + if (!IsEnabled) + return 0; + + long total = 0; + foreach (AudioEffect item in Children) + { + if (!item.IsEnabled) + continue; + + int latency = item.GetLatencySamples(sampleRate); + if (latency < 0) + { + throw new InvalidOperationException( + $"{item.GetType().Name} reported a negative latency ({latency} samples)."); + } + + if (latency == int.MaxValue) + return int.MaxValue; + + total += latency; + if (total >= int.MaxValue) + return int.MaxValue; + } + + return (int)total; + } } diff --git a/src/Beutl.Engine/Audio/Effects/LimiterEffect.cs b/src/Beutl.Engine/Audio/Effects/LimiterEffect.cs index 776be0322d..72de015205 100644 --- a/src/Beutl.Engine/Audio/Effects/LimiterEffect.cs +++ b/src/Beutl.Engine/Audio/Effects/LimiterEffect.cs @@ -94,4 +94,17 @@ public override AudioNode CreateNode(AudioContext context, AudioNode inputNode) context.Connect(inputNode, limiterNode); return limiterNode; } + + public override int GetLatencySamples(int sampleRate) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(sampleRate); + if (!IsEnabled) + return 0; + + // Match LimiterNode: an animated lookahead reports the worst case so a host querying before + // graph construction reserves the same room the node would. + return Lookahead.Animation != null + ? ToLatencySamples(MaxLookaheadMs, sampleRate) + : ToLatencySamples(Lookahead.CurrentValue, sampleRate); + } } diff --git a/src/Beutl.Engine/Audio/Effects/LimiterParameters.cs b/src/Beutl.Engine/Audio/Effects/LimiterParameters.cs index fb884888e9..410a66cd46 100644 --- a/src/Beutl.Engine/Audio/Effects/LimiterParameters.cs +++ b/src/Beutl.Engine/Audio/Effects/LimiterParameters.cs @@ -23,4 +23,21 @@ internal static class LimiterParameters public const float MinMakeupGainDb = -24f; public const float MaxMakeupGainDb = 24f; public const float DefaultMakeupGainDb = 0f; + + // Recomputes the ceiling from sampleRate (rather than reading LimiterNode's cached + // _maxLookaheadSamples) so latency can be reported before the node initializes its buffers. + // Must stay in sync with the clamp in LimiterNode.Derive; kept out of Derive, which runs + // per-sample on the animated path and cannot pay this recompute. + public static int ToLatencySamples(float lookaheadMs, int sampleRate) + { + if (sampleRate <= 0) + throw new ArgumentOutOfRangeException(nameof(sampleRate), "Sample rate must be positive."); + + int maxLookaheadSamples = Math.Max(1, (int)(MaxLookaheadMs / 1000f * sampleRate) + 1); + if (!float.IsFinite(lookaheadMs)) + lookaheadMs = MinLookaheadMs; + + lookaheadMs = Math.Clamp(lookaheadMs, MinLookaheadMs, MaxLookaheadMs); + return Math.Clamp((int)(lookaheadMs / 1000f * sampleRate), 0, maxLookaheadSamples - 1); + } } diff --git a/src/Beutl.Engine/Audio/Graph/AudioContext.cs b/src/Beutl.Engine/Audio/Graph/AudioContext.cs index 2cc8cfd951..c8e31eafce 100644 --- a/src/Beutl.Engine/Audio/Graph/AudioContext.cs +++ b/src/Beutl.Engine/Audio/Graph/AudioContext.cs @@ -11,8 +11,9 @@ namespace Beutl.Audio.Graph; public sealed class AudioContext : IDisposable { private readonly List _nodes = new(); - private readonly Dictionary> _connections = new(); - private readonly HashSet _outputNodes = new(); + private readonly Dictionary> _connections = + new(ReferenceEqualityComparer.Instance); + private readonly HashSet _outputNodes = new(ReferenceEqualityComparer.Instance); private List? _previousNodes; private AudioNode? _currentNode; private bool _disposed; @@ -62,7 +63,7 @@ public T AddNode(T node) where T : AudioNode ThrowIfDisposed(); ArgumentNullException.ThrowIfNull(node, nameof(node)); - if (!_nodes.Contains(node)) + if (!ContainsReference(_nodes, node)) { _nodes.Add(node); _connections[node] = new List(); @@ -107,7 +108,7 @@ public TNode CreateNode( .FirstOrDefault(n => comparer(parameters, n)); if (existing != null) { - _previousNodes.Remove(existing); + RemoveReference(_previousNodes, existing); existing.ClearInputs(); updater(parameters, existing); return AddNode(existing); @@ -135,7 +136,7 @@ public SourceNode CreateSourceNode(SoundSource.Resource source) .FirstOrDefault(n => source.Compare(n.Source)); if (existing != null) { - _previousNodes.Remove(existing); + RemoveReference(_previousNodes, existing); existing.ClearInputs(); return AddNode(existing); } @@ -166,7 +167,7 @@ public GainNode CreateGainNode(IProperty gain) { // Matched by Gain reference, so existing.Gain already == gain; no re-assignment needed // (and Gain is now init-only). - _previousNodes.Remove(existing); + RemoveReference(_previousNodes, existing); existing.ClearInputs(); return AddNode(existing); } @@ -195,7 +196,7 @@ public ShiftNode CreateShiftNode(TimeSpan shift) .FirstOrDefault(n => n.Shift == shift); if (existing != null) { - _previousNodes.Remove(existing); + RemoveReference(_previousNodes, existing); existing.ClearInputs(); return AddNode(existing); } @@ -226,7 +227,7 @@ public ClipNode CreateClipNode(TimeSpan start, TimeSpan duration) .FirstOrDefault(n => n.Duration == duration && n.Start == start); if (existing != null) { - _previousNodes.Remove(existing); + RemoveReference(_previousNodes, existing); existing.ClearInputs(); return AddNode(existing); } @@ -254,7 +255,7 @@ public MixerNode CreateMixerNode() var existing = _previousNodes.OfType().FirstOrDefault(); if (existing != null) { - _previousNodes.Remove(existing); + RemoveReference(_previousNodes, existing); existing.ClearInputs(); return AddNode(existing); } @@ -281,7 +282,7 @@ public ResampleNode CreateResampleNode(int sourceSampleRate) .FirstOrDefault(n => n.SourceSampleRate == sourceSampleRate); if (existing != null) { - _previousNodes.Remove(existing); + RemoveReference(_previousNodes, existing); existing.ClearInputs(); return AddNode(existing); } @@ -310,7 +311,7 @@ public SpeedNode CreateSpeedNode(IProperty speed) .FirstOrDefault(n => n.Speed == speed); if (existing != null) { - _previousNodes.Remove(existing); + RemoveReference(_previousNodes, existing); existing.ClearInputs(); existing.Speed = speed; return AddNode(existing); @@ -353,12 +354,12 @@ public void Connect(AudioNode source, AudioNode destination) ArgumentNullException.ThrowIfNull(source, nameof(source)); ArgumentNullException.ThrowIfNull(destination, nameof(destination)); - if (source == destination) + if (ReferenceEquals(source, destination)) throw new ArgumentException("Cannot connect a node to itself."); - if (!_nodes.Contains(source)) + if (!ContainsReference(_nodes, source)) AddNode(source); - if (!_nodes.Contains(destination)) + if (!ContainsReference(_nodes, destination)) AddNode(destination); destination.AddInput(source); @@ -377,7 +378,7 @@ public void MarkAsOutput(AudioNode node) ThrowIfDisposed(); ArgumentNullException.ThrowIfNull(node, nameof(node)); - if (!_nodes.Contains(node)) + if (!ContainsReference(_nodes, node)) AddNode(node); _outputNodes.Add(node); @@ -393,7 +394,7 @@ public T SetCurrent(T node) where T : AudioNode ThrowIfDisposed(); ArgumentNullException.ThrowIfNull(node, nameof(node)); - if (!_nodes.Contains(node)) + if (!ContainsReference(_nodes, node)) throw new ArgumentException("Node must be added to the context first.", nameof(node)); _currentNode = node; @@ -469,19 +470,19 @@ public void RemoveNode(AudioNode node) ThrowIfDisposed(); ArgumentNullException.ThrowIfNull(node, nameof(node)); - if (!_nodes.Contains(node)) + if (!ContainsReference(_nodes, node)) return; // Remove from all connections _connections.Remove(node); foreach (var list in _connections.Values) { - list.Remove(node); + RemoveReference(list, node); } // Remove from other tracking _outputNodes.Remove(node); - if (_currentNode == node) + if (ReferenceEquals(_currentNode, node)) _currentNode = null; // Clear inputs of other nodes that reference this one @@ -491,7 +492,7 @@ public void RemoveNode(AudioNode node) } // Finally remove from nodes list - _nodes.Remove(node); + RemoveReference(_nodes, node); } /// @@ -523,7 +524,7 @@ public void EndUpdate() { foreach (var prevNode in _previousNodes) { - if (!_nodes.Contains(prevNode)) + if (!ContainsReference(_nodes, prevNode)) { foreach (AudioNode node in _nodes) { @@ -546,6 +547,31 @@ public void Dispose() _disposed = true; } + private static bool ContainsReference(IReadOnlyList nodes, AudioNode node) + { + for (int i = 0; i < nodes.Count; i++) + { + if (ReferenceEquals(nodes[i], node)) + return true; + } + + return false; + } + + private static bool RemoveReference(List nodes, AudioNode node) + { + for (int i = 0; i < nodes.Count; i++) + { + if (ReferenceEquals(nodes[i], node)) + { + nodes.RemoveAt(i); + return true; + } + } + + return false; + } + private void ThrowIfDisposed() { if (_disposed) diff --git a/src/Beutl.Engine/Audio/Graph/AudioNode.cs b/src/Beutl.Engine/Audio/Graph/AudioNode.cs index 223d404a99..049bdee2f3 100644 --- a/src/Beutl.Engine/Audio/Graph/AudioNode.cs +++ b/src/Beutl.Engine/Audio/Graph/AudioNode.cs @@ -11,25 +11,205 @@ public void AddInput(AudioNode input) { ArgumentNullException.ThrowIfNull(input); - if (_inputs.Contains(input)) + if (IndexOfInput(input) >= 0) return; + int index = _inputs.Count; _inputs.Add(input); + try + { + OnInputAdded(input, index); + } + catch + { + _inputs.RemoveAt(index); + throw; + } } public void RemoveInput(AudioNode input) { ArgumentNullException.ThrowIfNull(input); - _inputs.Remove(input); + + int index = IndexOfInput(input); + if (index < 0) + return; + + AudioNode[] previousInputs = [.. _inputs]; + _inputs.RemoveAt(index); + try + { + OnInputRemoved(input, index); + } + catch + { + _inputs.Clear(); + _inputs.AddRange(previousInputs); + throw; + } } public void ClearInputs() { + AudioNode[] previousInputs = [.. _inputs]; _inputs.Clear(); + try + { + OnInputsCleared(); + } + catch + { + _inputs.Clear(); + _inputs.AddRange(previousInputs); + throw; + } + } + + /// Called after an input is appended, allowing derived nodes to keep connection metadata + /// aligned with . If this hook throws, the input list is restored to its + /// pre-call state. An override that mutates its own state must provide the same strong exception + /// guarantee by undoing those mutations before it propagates an exception. + protected virtual void OnInputAdded(AudioNode input, int index) + { + } + + /// Called after an input is removed, with its former position in + /// . If this hook throws, the input list is restored to its pre-call + /// state. An override that mutates its own state must provide the same strong exception guarantee + /// by undoing those mutations before it propagates an exception. + protected virtual void OnInputRemoved(AudioNode input, int index) + { + } + + /// Called whenever is invoked, including when there were no + /// inputs. This hook is not part of disposal; derived nodes that own disposable connection + /// metadata must release it from . If this hook throws, the input list + /// is restored to its pre-call state. An override that mutates its own state must provide the same + /// strong exception guarantee by undoing those mutations before it propagates an exception. + protected virtual void OnInputsCleared() + { + } + + private int IndexOfInput(AudioNode input) + { + for (int i = 0; i < _inputs.Count; i++) + { + if (ReferenceEquals(_inputs[i], input)) + return i; + } + + return -1; } public abstract AudioBuffer Process(AudioProcessContext context); + /// + /// Applies this node's own processing to an already-produced buffer + /// instead of pulling [0] itself. feeds it real upstream + /// audio ( is ); feeds it the + /// drained tail ( is ), so a transforming node + /// processes the tail the same way it processes the body. A node whose output geometry is driven by + /// an animated parameter (a lookahead delay) must, while draining, hold that parameter at the value + /// retained from the clip's terminal sample rather than re-sampling automation over the post-clip + /// range — otherwise it reads the wrong tail. The default is pass-through (returns + /// unchanged), keeping the zero-processing path byte-identical. A node that + /// Ownership of transfers to this method on entry. An override that returns + /// a fresh buffer must dispose before returning, and an override that throws + /// must dispose it before propagating the exception. Returning transfers + /// that same buffer to the caller. + /// + protected virtual AudioBuffer ProcessTail(AudioBuffer input, AudioProcessContext context, bool draining) => input; + + /// Channel layout this node's last emitted; the silent-flush + /// fallback matches it so a flush never changes the channel count a downstream node just saw. + protected int LastProcessedChannelCount { get; private set; } = 2; + + /// Records the channel count a override is about to emit, so + /// can mirror it. Leaf/source nodes call this from Process. + /// Known limitation: a custom zero-input leaf that omits this call keeps the default stereo count, + /// so a mono/multichannel leaf would flush stereo silence and make a downstream limiter reinitialize + /// and drop its tail. Built-in records correctly; a wider contract + /// (the base learning the emitted layout) is a follow-up. + protected void RecordProcessedChannelCount(int channelCount) => LastProcessedChannelCount = channelCount; + + /// The silence a node with no live source emits when flushed; sized to the last processed + /// channel layout. Override for a node whose flush silence needs a different shape. + protected virtual AudioBuffer CreateSilentFlush(AudioProcessContext context) + => new(context.SampleRate, LastProcessedChannelCount, context.GetSampleCount()); + + /// + /// Drains the latency this node and its still hold, as the + /// -sized block that follows the clip's last output. + /// The real source is treated as exhausted — a node with no inputs returns silence — so the only + /// non-silent content is what delay lines / lookahead buffers release; that is why a trimmed clip + /// cannot bleed real audio here. Callers must invoke it immediately after the terminal + /// with a context that abuts it, so the cached node sees no discontinuity. + /// A single-input node runs its upstream's drain through its own , so + /// downstream effects still shape the tail; a fan-in node must override to drain and merge branches. + /// + public virtual AudioBuffer Flush(AudioProcessContext context) + { + ArgumentNullException.ThrowIfNull(context); + + if (_inputs.Count == 0) + return CreateSilentFlush(context); + + if (_inputs.Count == 1) + { + return ProcessTail(_inputs[0].Flush(context), context, draining: true); + } + + throw new InvalidOperationException( + $"{GetType().Name} has {_inputs.Count} inputs; override Flush to drain and merge them."); + } + + /// + /// Reports the processing latency this node alone introduces at , in + /// samples (a lookahead/delay-line node returns the samples its output lags its input; pass-through + /// nodes return 0). Report-only: it never affects output. Pass the output + /// (post-resample) sample rate, since latency is rate-dependent. Valid results are in the inclusive + /// range 0..; denotes an unbounded or saturated + /// latency budget. + /// + /// is not positive. + public virtual int GetLatencySamples(int sampleRate) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(sampleRate); + return 0; + } + + /// + /// Latency accumulated along the path feeding this node's output: this node's own latency plus the + /// largest total latency among its . A single-input cascade therefore sums, + /// while a fan-in node (a mixer) takes the slowest branch — the alignment a compensator would use. + /// Override to impose a different upstream fold (e.g. a weighted-sum mixer). Requires an acyclic + /// input graph, the same precondition already relies on. + /// denotes an unbounded or saturated budget and is propagated without + /// overflow. + /// + /// is not positive. + public virtual int GetTotalLatencySamples(int sampleRate) + { + // Guard at the entry point, not just via the GetLatencySamples call below: an override may fold + // the upstream recursion before reaching it, so the contract has to hold here too. + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(sampleRate); + + int upstream = 0; + foreach (AudioNode input in _inputs) + { + int inputTotal = input.GetTotalLatencySamples(sampleRate); + if (inputTotal > upstream) + upstream = inputTotal; + } + + int ownLatency = GetLatencySamples(sampleRate); + if (upstream == int.MaxValue || ownLatency == int.MaxValue) + return int.MaxValue; + + long total = (long)ownLatency + upstream; + return total >= int.MaxValue ? int.MaxValue : (int)total; + } + protected virtual void Dispose(bool disposing) { if (!_disposed) diff --git a/src/Beutl.Engine/Audio/Graph/AudioProcessContext.cs b/src/Beutl.Engine/Audio/Graph/AudioProcessContext.cs index 68f6a7c3ef..fed3818e5d 100644 --- a/src/Beutl.Engine/Audio/Graph/AudioProcessContext.cs +++ b/src/Beutl.Engine/Audio/Graph/AudioProcessContext.cs @@ -56,6 +56,56 @@ public static int GetSampleCount(TimeRange range, int sampleRate) return (int)samples; } + /// + /// Returns a representable duration whose ceiling-based sample count is exactly + /// . + /// + /// + /// may round a quotient upward by one tick. Feeding that + /// duration back through can therefore consume one + /// extra sample. Taking the greatest whole-tick duration that does not exceed the exact sample + /// boundary keeps stateful audio nodes and their callers on the same sample count. + /// + /// + /// is negative, is not positive, or + /// the requested sample count cannot be represented at the given rate with + /// tick precision. + /// + public static TimeSpan GetDurationForSampleCount(int sampleCount, int sampleRate) + { + ArgumentOutOfRangeException.ThrowIfNegative(sampleCount); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(sampleRate); + + if (sampleCount == 0) + return TimeSpan.Zero; + + long ticks = (long)sampleCount * TimeSpan.TicksPerSecond / sampleRate; + if (ticks == 0) + { + throw new ArgumentOutOfRangeException( + nameof(sampleRate), + $"Sample rate {sampleRate} is too high to represent {sampleCount} samples as a positive TimeSpan."); + } + + var duration = TimeSpan.FromTicks(ticks); + int roundTripped = GetSampleCount(new TimeRange(TimeSpan.Zero, duration), sampleRate); + while (roundTripped > sampleCount && ticks > 0) + { + duration = TimeSpan.FromTicks(--ticks); + roundTripped = GetSampleCount(new TimeRange(TimeSpan.Zero, duration), sampleRate); + } + + if (roundTripped != sampleCount) + { + throw new ArgumentOutOfRangeException( + nameof(sampleRate), + $"Could not represent exactly {sampleCount} samples at sampleRate={sampleRate}; " + + $"the closest duration produced {roundTripped} samples."); + } + + return duration; + } + /// /// Returns whether this chunk continues directly from a previous chunk that ended at . /// diff --git a/src/Beutl.Engine/Audio/Graph/Nodes/ClipNode.cs b/src/Beutl.Engine/Audio/Graph/Nodes/ClipNode.cs index c92677511a..e32ffaf259 100644 --- a/src/Beutl.Engine/Audio/Graph/Nodes/ClipNode.cs +++ b/src/Beutl.Engine/Audio/Graph/Nodes/ClipNode.cs @@ -5,6 +5,11 @@ namespace Beutl.Audio.Graph.Nodes; // タイムライン上の時間空間をローカル時間空間に変換 public class ClipNode : AudioNode { + // Clip-local end of the last window this node actually processed. Equals Duration when the clip ran + // to its own end, but is earlier when a parent trims it (a SoundGroup window stopping before the + // child's Duration); Flush drains from here so the cached effects stay contiguous either way. + private TimeSpan? _lastProcessedLocalEnd; + public TimeSpan Start { get; set; } = TimeSpan.Zero; public TimeSpan Duration { get; set; } = TimeSpan.Zero; @@ -34,6 +39,7 @@ public override AudioBuffer Process(AudioProcessContext context) context.SampleRate, context.AnimationSampler, context.OriginalTimeRange); + _lastProcessedLocalEnd = newRange.End - Start; using var buffer = Inputs[0].Process(clippedContext); var newBuffer = new AudioBuffer( context.SampleRate, @@ -52,6 +58,16 @@ public override AudioBuffer Process(AudioProcessContext context) buffer.CopyTo(newBuffer, offset, copyCount); } + // When this window reaches the clip's true end, the effect chain still holds its tail in + // delay lines. Drain it (Flush feeds silence past the clip end, so a trimmed source never + // bleeds) and append it into the trailing pad the window already reserves, recovering audio + // that inline processing would otherwise drop off the tail. The drain is contiguous with the + // main slice in clip-local time, so the cached effect state does not reset. + if (newRange.End == range.End) + { + AppendFlushedTail(context, newBuffer, offset + copyCount); + } + return newBuffer; } catch @@ -61,4 +77,73 @@ public override AudioBuffer Process(AudioProcessContext context) throw; } } + + // A nested ClipNode (one feeding another clip's graph — e.g. a SoundGroup child mixed under a group + // clip) is flushed by its parent in the PARENT's time domain. Process remaps an incoming timeline + // window to clip-local before pulling the input, so the flush path must reconstruct the same + // clip-local frame or the child's cached effects (a lookahead limiter) see a discontinuity, Reset(), + // and drop the very tail being drained. Drain from the clip-local end of the last processed window — + // Duration when the clip ran to its own end, earlier when a parent trimmed it — so the cached chain + // stays contiguous; the parent's start is intentionally dropped, which is also why an intervening + // ShiftNode needs no flush override of its own. + public override AudioBuffer Flush(AudioProcessContext context) + { + ArgumentNullException.ThrowIfNull(context); + + var drainContext = new AudioProcessContext( + new TimeRange(_lastProcessedLocalEnd ?? Duration, context.TimeRange.Duration), + context.SampleRate, + context.AnimationSampler, + context.OriginalTimeRange); + + var result = Inputs[0].Flush(drainContext); + + // A parent may flush this same clip across multiple blocks when its tail capacity is below the + // child's latency; advancing here keeps the next drain contiguous instead of replaying from the + // old local end and tripping the cached effects' discontinuity guard (as AppendFlushedTail does). + _lastProcessedLocalEnd = drainContext.TimeRange.End; + return result; + } + + // Drains the input chain's residual latency into newBuffer starting at writeOffset, bounded by the + // remaining capacity. The drain context starts clip-local at Duration — exactly where the main + // slice ended on the terminal window — so the cached effect chain sees a contiguous stream. + private void AppendFlushedTail(AudioProcessContext context, AudioBuffer newBuffer, int writeOffset) + { + // Known limitation: when the compose window ends exactly at the clip boundary the window is + // already full (capacity == 0) and the tail has nowhere to go; the next window legitimately + // excludes the clip (TimeRange.Intersects is half-open), so a few ms of tail is lost on that + // alignment. The only fixes — range-expanding the Composer or emitting an oversized terminal + // buffer — are out of scope (the former spuriously resets the limiter; the latter breaks the + // output-length contract). Tracked as a follow-up. + int capacity = newBuffer.SampleCount - writeOffset; + if (capacity <= 0) + return; + + int latency = Inputs[0].GetTotalLatencySamples(context.SampleRate); + int drainCount = Math.Min(latency, capacity); + if (drainCount <= 0) + return; + + var drainContext = new AudioProcessContext( + new TimeRange( + Duration, + AudioProcessContext.GetDurationForSampleCount(drainCount, context.SampleRate)), + context.SampleRate, + context.AnimationSampler, + context.OriginalTimeRange); + + using var tail = Inputs[0].Flush(drainContext); + int copyCount = Math.Min(tail.SampleCount, drainCount); + if (copyCount > 0) + { + tail.CopyTo(newBuffer, writeOffset, copyCount); + } + + // This drain advanced the upstream chain to the end of the drain block. Record it so that if a + // parent later flushes this same clip to recover the rest of a partially-drained tail (capacity + // was below the reported latency), Flush continues from here instead of stepping back to Duration + // and tripping the cached effects' discontinuity guard. + _lastProcessedLocalEnd = drainContext.TimeRange.End; + } } diff --git a/src/Beutl.Engine/Audio/Graph/Nodes/DelayNode.cs b/src/Beutl.Engine/Audio/Graph/Nodes/DelayNode.cs index d9936e5ed6..010f494c78 100644 --- a/src/Beutl.Engine/Audio/Graph/Nodes/DelayNode.cs +++ b/src/Beutl.Engine/Audio/Graph/Nodes/DelayNode.cs @@ -25,8 +25,19 @@ public override AudioBuffer Process(AudioProcessContext context) if (Inputs.Count != 1) throw new InvalidOperationException("Delay node requires exactly one input."); + return ProcessTail(Inputs[0].Process(context), context, draining: false); + } + + // Deliberately keeps the base GetLatencySamples of 0: an echo/delay is an intentional, audible + // delay (render-tail-time territory), not processing latency that PDC compensates — reporting it + // would shift the dry signal earlier and double-count the effect. A consequence is that a wet-only + // delay placed after a lookahead limiter is not granted extra flush budget, so its delayed tail can + // still be clipped at the clip boundary; recovering that is a separate render-tail-time concern. + + protected override AudioBuffer ProcessTail(AudioBuffer input, AudioProcessContext context, bool draining) + { // Every path emits a fresh buffer (no pass-through), so dispose the consumed input. - using var input = Inputs[0].Process(context); + using var owned = input; // Reinitialize on a sample-rate or channel-count change; otherwise a channel-count increase // would leave the extra channels unprocessed (silent). diff --git a/src/Beutl.Engine/Audio/Graph/Nodes/DynamicsNode.cs b/src/Beutl.Engine/Audio/Graph/Nodes/DynamicsNode.cs index e93641f6b5..50a793d11e 100644 --- a/src/Beutl.Engine/Audio/Graph/Nodes/DynamicsNode.cs +++ b/src/Beutl.Engine/Audio/Graph/Nodes/DynamicsNode.cs @@ -50,7 +50,11 @@ public override AudioBuffer Process(AudioProcessContext context) throw new InvalidOperationException( $"{DiagnosticName} node requires exactly one input but got {Inputs.Count}."); - AudioBuffer input = Inputs[0].Process(context); + return ProcessTail(Inputs[0].Process(context), context, draining: false); + } + + protected override AudioBuffer ProcessTail(AudioBuffer input, AudioProcessContext context, bool draining) + { bool ownsInput = true; try { diff --git a/src/Beutl.Engine/Audio/Graph/Nodes/EqualizerNode.cs b/src/Beutl.Engine/Audio/Graph/Nodes/EqualizerNode.cs index e4ba552993..c78f1bfb12 100644 --- a/src/Beutl.Engine/Audio/Graph/Nodes/EqualizerNode.cs +++ b/src/Beutl.Engine/Audio/Graph/Nodes/EqualizerNode.cs @@ -24,8 +24,11 @@ public override AudioBuffer Process(AudioProcessContext context) if (Inputs.Count != 1) throw new InvalidOperationException("Equalizer node requires exactly one input."); - var input = Inputs[0].Process(context); + return ProcessTail(Inputs[0].Process(context), context, draining: false); + } + protected override AudioBuffer ProcessTail(AudioBuffer input, AudioProcessContext context, bool draining) + { // Pass-through if no bands (caller owns it, don't dispose). if (Bands.Count == 0) { diff --git a/src/Beutl.Engine/Audio/Graph/Nodes/GainNode.cs b/src/Beutl.Engine/Audio/Graph/Nodes/GainNode.cs index 2abcaab16d..4035d723f0 100644 --- a/src/Beutl.Engine/Audio/Graph/Nodes/GainNode.cs +++ b/src/Beutl.Engine/Audio/Graph/Nodes/GainNode.cs @@ -11,8 +11,11 @@ public override AudioBuffer Process(AudioProcessContext context) if (Inputs.Count != 1) throw new InvalidOperationException("Gain node requires exactly one input."); - var input = Inputs[0].Process(context); + return ProcessTail(Inputs[0].Process(context), context, draining: false); + } + protected override AudioBuffer ProcessTail(AudioBuffer input, AudioProcessContext context, bool draining) + { if (Gain.Animation is null) { return ProcessStaticGain(input); diff --git a/src/Beutl.Engine/Audio/Graph/Nodes/LimiterNode.cs b/src/Beutl.Engine/Audio/Graph/Nodes/LimiterNode.cs index 0e82bdf788..565bf20694 100644 --- a/src/Beutl.Engine/Audio/Graph/Nodes/LimiterNode.cs +++ b/src/Beutl.Engine/Audio/Graph/Nodes/LimiterNode.cs @@ -36,6 +36,13 @@ public sealed class LimiterNode : AudioNode private int _dequeLookahead = -1; private long _globalPos; + // The coefficients the last real-audio sample was processed with. A drain (Flush) following the + // clip end keeps its lookahead offset so the held tail is read where it was buffered, while the + // other animated parameters continue sampling over the drain range. Invalid after Reset/format + // change (cold state). + private DerivedCoefficients _lastDerived; + private bool _hasLastDerived; + // Per-parameter latches that keep audio-rate logging from spamming the sink (one latch each so // a non-finite Threshold doesn't mask a non-finite Release). Cleared only on full // re-initialization, not on chunk discontinuity — otherwise a persistent defect logs every chunk. @@ -63,9 +70,20 @@ public override AudioBuffer Process(AudioProcessContext context) throw new InvalidOperationException( $"LimiterNode requires exactly one input but has {Inputs.Count}."); - // Every path emits a fresh buffer (no pass-through), so dispose the consumed input. - using var input = Inputs[0].Process(context) - ?? throw new InvalidOperationException("LimiterNode: upstream Process returned null."); + return ProcessTail( + Inputs[0].Process(context) + ?? throw new InvalidOperationException("LimiterNode: upstream Process returned null."), + context, + draining: false); + } + + // Shared by Process (real upstream audio) and the base Flush (drained tail): the drained block + // runs through the same delay-line path, so the lookahead tail still held is emitted. The flush + // block abuts the terminal chunk, so the contiguity check below does not reset. + protected override AudioBuffer ProcessTail(AudioBuffer input, AudioProcessContext context, bool draining) + { + // ProcessTail owns its input on every path, including validation failures. + using var owned = input; if (input.SampleRate != context.SampleRate) throw new InvalidOperationException( @@ -116,9 +134,21 @@ public override AudioBuffer Process(AudioProcessContext context) || Lookahead.Animation != null || MakeupGain.Animation != null; - var output = hasAnimation - ? ProcessAnimated(input, context) - : ProcessStatic(input, context); + AudioBuffer output; + if (draining && Lookahead.Animation != null && _hasLastDerived) + { + // Re-sampling Lookahead over the post-clip drain range reads the wrong delay offset: the held + // tail was buffered at the lookahead in effect at the clip end, but the automation may have + // moved off it. Keep only that offset fixed; Threshold, Release, and MakeupGain still sample + // at their drain-range times so their automation remains live. + output = DrainWithFrozenLookahead(input, context, _lastDerived.LookaheadSamples); + } + else + { + output = hasAnimation + ? ProcessAnimated(input, context) + : ProcessStatic(input, context); + } // Update only on success. If Process throws mid-buffer the state is left partially mutated, // but no production caller retries the same chunk: a next call at a different Start hits the @@ -131,6 +161,21 @@ public override AudioBuffer Process(AudioProcessContext context) return output; } + /// + /// Reports the lookahead delay this limiter applies, in samples at . + /// An animated varies the delay per sample, so the report cannot track a + /// single value; it returns the worst case (the maximum lookahead) so a tail-drain reserves enough + /// room even when the automation peaks near the clip end. + /// + public override int GetLatencySamples(int sampleRate) + { + // Explicit guard so a future early-return added before the delegate cannot bypass the rate contract. + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(sampleRate); + return Lookahead.Animation != null + ? LimiterParameters.ToLatencySamples(LimiterParameters.MaxLookaheadMs, sampleRate) + : LimiterParameters.ToLatencySamples(Lookahead.CurrentValue, sampleRate); + } + private void InitializeBuffers(int sampleRate, int channelCount) { // Null the fields up front so a throw in the construction loop below can't leave us @@ -194,6 +239,7 @@ private void InitializeBuffers(int sampleRate, int channelCount) // A format change makes the previous warning history irrelevant, so re-arm the latches: // a persistent upstream defect should surface once per format change, not just once ever. _currentGain = 1f; + _hasLastDerived = false; _warnedNonFiniteThreshold = false; _warnedNonFiniteRelease = false; _warnedNonFiniteLookahead = false; @@ -233,25 +279,39 @@ private readonly record struct DerivedCoefficients( float ReleaseCoef); private DerivedCoefficients Derive(float thresholdDbRaw, float releaseMsRaw, float lookaheadMsRaw, float makeupDbRaw, int sampleRate) + { + float lookaheadMs = ClampFinite(lookaheadMsRaw, MinLookaheadMs, MaxLookaheadMs, MinLookaheadMs, nameof(Lookahead), ref _warnedNonFiniteLookahead); + int lookaheadSamples = Math.Clamp((int)(lookaheadMs / 1000f * sampleRate), 0, _maxLookaheadSamples - 1); + return DeriveWithLookaheadSamples( + thresholdDbRaw, releaseMsRaw, makeupDbRaw, sampleRate, lookaheadSamples); + } + + private DerivedCoefficients DeriveWithLookaheadSamples( + float thresholdDbRaw, + float releaseMsRaw, + float makeupDbRaw, + int sampleRate, + int lookaheadSamples) { // Threshold falls back to the default (-1 dB), not MaxThresholdDb (0 dB): 0 dB would // silently disable limiting for the rest of the session — the exact silent failure this // guard prevents. -1 dB still limits, peaking just under unity so the issue stays audible. float thresholdDb = ClampFinite(thresholdDbRaw, MinThresholdDb, MaxThresholdDb, DefaultThresholdDb, nameof(Threshold), ref _warnedNonFiniteThreshold); float releaseMs = ClampFinite(releaseMsRaw, MinReleaseMs, MaxReleaseMs, MinReleaseMs, nameof(Release), ref _warnedNonFiniteRelease); - float lookaheadMs = ClampFinite(lookaheadMsRaw, MinLookaheadMs, MaxLookaheadMs, MinLookaheadMs, nameof(Lookahead), ref _warnedNonFiniteLookahead); float makeupDb = ClampFinite(makeupDbRaw, MinMakeupGainDb, MaxMakeupGainDb, 0f, nameof(MakeupGain), ref _warnedNonFiniteMakeup); return new DerivedCoefficients( ThresholdLin: AudioMath.ConvertDbToLinear(thresholdDb), MakeupLin: AudioMath.ConvertDbToLinear(makeupDb), - LookaheadSamples: Math.Clamp((int)(lookaheadMs / 1000f * sampleRate), 0, _maxLookaheadSamples - 1), + LookaheadSamples: Math.Clamp(lookaheadSamples, 0, _maxLookaheadSamples - 1), ReleaseCoef: MathF.Exp(-1f / (releaseMs * 0.001f * sampleRate))); } private AudioBuffer ProcessStatic(AudioBuffer input, AudioProcessContext context) { var c = Derive(Threshold.CurrentValue, Release.CurrentValue, Lookahead.CurrentValue, MakeupGain.CurrentValue, context.SampleRate); + _lastDerived = c; + _hasLastDerived = true; var output = new AudioBuffer(input.SampleRate, input.ChannelCount, input.SampleCount); try @@ -326,6 +386,8 @@ private AudioBuffer ProcessAnimated(AudioBuffer input, AudioProcessContext conte // sample-accurate automation. The common no-animation case takes ProcessStatic, // which derives the coefficients once per call. var c = Derive(thresholds[i], releases[i], lookaheads[i], makeups[i], context.SampleRate); + _lastDerived = c; + _hasLastDerived = true; int idx = processed + i; // The window maximum comes from ScanWindowPeak over the ring, so IngestSample's // returned peak is ignored here (only its delay-line/peak-ring side-effects matter). @@ -348,6 +410,76 @@ private AudioBuffer ProcessAnimated(AudioBuffer input, AudioProcessContext conte } } + // Drains the held tail at the clip's terminal lookahead while keeping the other parameters + // sample-accurate over the drain range. The fixed offset reads the delay line where the tail was + // buffered; live Threshold, Release, and MakeupGain preserve their normal automation semantics. + private AudioBuffer DrainWithFrozenLookahead( + AudioBuffer input, + AudioProcessContext context, + int lookaheadSamples) + { + var output = new AudioBuffer(input.SampleRate, input.ChannelCount, input.SampleCount); + try + { + int scratchSize = Math.Min(AnimationChunkSize, input.SampleCount); + Span thresholds = stackalloc float[scratchSize]; + Span releases = stackalloc float[scratchSize]; + Span makeups = stackalloc float[scratchSize]; + + // The deque does not track this drain block (it reads via ScanWindowPeak like the animated + // path), so invalidate it for a later static chunk to rebuild. + _dequeLookahead = -1; + + int channelCount = _delayLines!.Length; + int sampleCount = input.SampleCount; + ReadOnlySpan inRaw = input.GetRawSpan(); + Span outRaw = output.GetRawSpan(); + + int processed = 0; + while (processed < sampleCount) + { + int chunkSize = Math.Min(AnimationChunkSize, sampleCount - processed); + var chunkStart = context.GetTimeForSample(processed); + var chunkEnd = context.GetTimeForSample(processed + chunkSize); + var chunkRange = new TimeRange(chunkStart, chunkEnd - chunkStart); + + context.AnimationSampler.SampleBuffer(Threshold, chunkRange, context.SampleRate, thresholds[..chunkSize]); + context.AnimationSampler.SampleBuffer(Release, chunkRange, context.SampleRate, releases[..chunkSize]); + context.AnimationSampler.SampleBuffer(MakeupGain, chunkRange, context.SampleRate, makeups[..chunkSize]); + + for (int i = 0; i < chunkSize; i++) + { + var c = DeriveWithLookaheadSamples( + thresholds[i], releases[i], makeups[i], context.SampleRate, lookaheadSamples); + int idx = processed + i; + _ = IngestSample(inRaw, sampleCount, channelCount, idx); + float windowPeak = ScanWindowPeak(c.LookaheadSamples); + EmitSample( + outRaw, + sampleCount, + channelCount, + idx, + windowPeak, + c.ThresholdLin, + c.MakeupLin, + c.LookaheadSamples, + c.ReleaseCoef); + } + + processed += chunkSize; + } + + return output; + } + catch + { + // Dispose the output the caller never received rather than leak it. + output.Dispose(); + OutputBuffersDisposedAfterFailure++; + throw; + } + } + // Reads one input sample per channel, coerces non-finite values, feeds the delay lines and the // peak-detection ring, advances the global position, and returns the channel-linked peak. // inRaw is the channel-major backing span (channel ch sample i at ch * sampleCount + i), @@ -541,6 +673,9 @@ public void Reset() _currentGain = 1f; _lastTimeRangeEnd = null; + // The delay line is cold; a drain before the next real chunk has no retained coefficients to use. + _hasLastDerived = false; + // Discard the deque. _globalPos restarts at 0 to stay aligned with the cleared _peakBuffer, // and the lookahead marker is invalidated so the next static chunk rebuilds from scratch. _dqHead = 0; diff --git a/src/Beutl.Engine/Audio/Graph/Nodes/MixerNode.cs b/src/Beutl.Engine/Audio/Graph/Nodes/MixerNode.cs index 62ef684471..e1ae5fa55e 100644 --- a/src/Beutl.Engine/Audio/Graph/Nodes/MixerNode.cs +++ b/src/Beutl.Engine/Audio/Graph/Nodes/MixerNode.cs @@ -1,11 +1,14 @@ using System; -using System.Linq; namespace Beutl.Audio.Graph.Nodes; public sealed class MixerNode : AudioNode { + private const long BranchLivenessToleranceTicks = TimeSpan.TicksPerMillisecond; + private float[] _gains = Array.Empty(); + private readonly Dictionary _branchEndTimes = + new(ReferenceEqualityComparer.Instance); public float[] Gains { @@ -13,30 +16,91 @@ public float[] Gains set => _gains = value ?? Array.Empty(); } + /// + /// Records the group-local time when a connected input branch ends. A branch without an end time is + /// considered live, so dynamically added inputs keep draining until explicitly configured. + /// + /// is not connected to this mixer. + public void SetBranchEndTime(AudioNode input, TimeSpan endTime) + { + EnsureConnectedInput(input); + + _branchEndTimes[input] = endTime; + } + + /// + /// Removes the recorded end time for a connected input so the branch is considered live again. + /// + /// when an end time was removed; otherwise, . + /// is not connected to this mixer. + public bool ClearBranchEndTime(AudioNode input) + { + EnsureConnectedInput(input); + return _branchEndTimes.Remove(input); + } + public override AudioBuffer Process(AudioProcessContext context) + => Mix(context, drain: false); + + // Fan-in flush: drain every branch and mix the held tails with the same gain fold as Process, so a + // lookahead tail in any live branch is recovered (the base Flush's single-input path cannot reach + // here). A branch that ended before this drain block is skipped because its tail was already + // recovered at the child's own clip end. + public override AudioBuffer Flush(AudioProcessContext context) + { + ArgumentNullException.ThrowIfNull(context); + + if (Inputs.Count == 0) + return CreateSilentFlush(context); + + return Mix(context, drain: true); + } + + private AudioBuffer Mix(AudioProcessContext context, bool drain) { if (Inputs.Count == 0) throw new InvalidOperationException("Mixer requires at least one input."); - // Process all inputs + // A dead branch (its clip ended before this drain block) keeps a null slot: its tail was already + // recovered at its own clip end, so re-draining it here would leak a stale tail into the group + // pad. Liveness only applies while draining — Process mixes every branch. var buffers = new AudioBuffer[Inputs.Count]; try { for (int i = 0; i < Inputs.Count; i++) { - buffers[i] = Inputs[i].Process(context); + if (drain && IsBranchDead(i, context)) + continue; + + buffers[i] = drain ? Inputs[i].Flush(context) : Inputs[i].Process(context); + } + + // The format reference is the first live branch; every branch dead means nothing to drain. + AudioBuffer? firstBuffer = null; + foreach (var buffer in buffers) + { + if (buffer != null) + { + firstBuffer = buffer; + break; + } } - // Validate all buffers have the same format - var firstBuffer = buffers[0]; - for (int i = 1; i < buffers.Length; i++) + if (firstBuffer == null) + return CreateSilentFlush(context); + + // Validate live buffers have the same format + for (int i = 0; i < buffers.Length; i++) { - if (buffers[i].SampleRate != firstBuffer.SampleRate) - throw new InvalidOperationException($"All inputs must have the same sample rate. Expected {firstBuffer.SampleRate}, but input {i} has {buffers[i].SampleRate}."); - if (buffers[i].ChannelCount != firstBuffer.ChannelCount) - throw new InvalidOperationException($"All inputs must have the same channel count. Expected {firstBuffer.ChannelCount}, but input {i} has {buffers[i].ChannelCount}."); - if (buffers[i].SampleCount != firstBuffer.SampleCount) - throw new InvalidOperationException($"All inputs must have the same sample count. Expected {firstBuffer.SampleCount}, but input {i} has {buffers[i].SampleCount}."); + var buffer = buffers[i]; + if (buffer == null) + continue; + if (buffer.SampleRate != firstBuffer.SampleRate) + throw new InvalidOperationException($"All inputs must have the same sample rate. Expected {firstBuffer.SampleRate}, but input {i} has {buffer.SampleRate}."); + if (buffer.ChannelCount != firstBuffer.ChannelCount) + throw new InvalidOperationException($"All inputs must have the same channel count. Expected {firstBuffer.ChannelCount}, but input {i} has {buffer.ChannelCount}."); + if (buffer.SampleCount != firstBuffer.SampleCount) + throw new InvalidOperationException($"All inputs must have the same sample count. Expected {firstBuffer.SampleCount}, but input {i} has {buffer.SampleCount}."); } // Create output buffer @@ -51,11 +115,15 @@ public override AudioBuffer Process(AudioProcessContext context) // Clear output buffer (already cleared in constructor, but being explicit) outData.Clear(); - // Mix each input + // Mix each live input for (int i = 0; i < buffers.Length; i++) { + var inBuffer = buffers[i]; + if (inBuffer == null) + continue; + var gain = i < _gains.Length ? _gains[i] : 1.0f; - var inData = buffers[i].GetChannelData(ch); + var inData = inBuffer.GetChannelData(ch); // Add with gain for (int s = 0; s < output.SampleCount; s++) @@ -65,6 +133,7 @@ public override AudioBuffer Process(AudioProcessContext context) } } + RecordProcessedChannelCount(output.ChannelCount); return output; } catch @@ -77,12 +146,90 @@ public override AudioBuffer Process(AudioProcessContext context) } finally { - // Dispose every consumed input (also on the validation-throw path, where trailing - // slots may still be null). + // Dispose every consumed input (also on the validation-throw / dead-branch path, where + // slots may be null). foreach (var buffer in buffers) { buffer?.Dispose(); } } } + + private bool IsBranchDead(int index, AudioProcessContext context) + { + if (!_branchEndTimes.TryGetValue(Inputs[index], out TimeSpan branchEndTime)) + return false; + + // Dead = the branch's clip ended before this drain block. The tolerance absorbs sample-tick + // rounding so a branch ending exactly at the group end stays live and its tail still drains. + // Subtract from the block start with saturation instead of adding to the branch end, so even + // TimeSpan.MaxValue cannot overflow into a negative tick count. + long blockStartTicks = context.TimeRange.Start.Ticks; + long deadBeforeTicks = blockStartTicks < TimeSpan.MinValue.Ticks + BranchLivenessToleranceTicks + ? TimeSpan.MinValue.Ticks + : blockStartTicks - BranchLivenessToleranceTicks; + return branchEndTime.Ticks < deadBeforeTicks; + } + + protected override void OnInputAdded(AudioNode input, int index) + { + AppendDefaultIfConfigured(ref _gains, index, 1f); + } + + protected override void OnInputRemoved(AudioNode input, int index) + { + _gains = RemoveAt(_gains, index); + _branchEndTimes.Remove(input); + } + + protected override void OnInputsCleared() + { + _gains = Array.Empty(); + _branchEndTimes.Clear(); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _gains = Array.Empty(); + _branchEndTimes.Clear(); + } + + base.Dispose(disposing); + } + + private void EnsureConnectedInput(AudioNode input) + { + ArgumentNullException.ThrowIfNull(input); + + foreach (AudioNode connectedInput in Inputs) + { + if (ReferenceEquals(connectedInput, input)) + return; + } + + throw new ArgumentException("The input must be connected before configuring its branch end time.", nameof(input)); + } + + private static void AppendDefaultIfConfigured(ref T[] values, int index, T defaultValue) + { + if (values.Length == 0 || values.Length > index) + return; + + int oldLength = values.Length; + Array.Resize(ref values, index + 1); + Array.Fill(values, defaultValue, oldLength, values.Length - oldLength); + } + + private static T[] RemoveAt(T[] values, int index) + { + if (index >= values.Length) + return values; + + var result = new T[values.Length - 1]; + values.AsSpan(0, index).CopyTo(result); + values.AsSpan(index + 1).CopyTo(result.AsSpan(index)); + return result; + } } diff --git a/src/Beutl.Engine/Audio/Graph/Nodes/ResampleNode.cs b/src/Beutl.Engine/Audio/Graph/Nodes/ResampleNode.cs index a28a069430..50cee9732e 100644 --- a/src/Beutl.Engine/Audio/Graph/Nodes/ResampleNode.cs +++ b/src/Beutl.Engine/Audio/Graph/Nodes/ResampleNode.cs @@ -24,6 +24,13 @@ public int SourceSampleRate } } + // Known limitation: no Flush override. Process feeds its input a SourceSampleRate context and + // resamples the result to the output rate; the inherited flush instead forwards the output-rate + // context straight upstream and does not resample the drained tail. A latency node placed UPSTREAM + // of this resampler (initialized at SourceSampleRate) would then see a rate change on flush, reinit, + // and drop its tail. The built-in Sound chain puts ResampleNode before the effects, so a limiter is + // always downstream and runs at the output rate — this only bites a custom graph that resamples + // after a latency-bearing node; a rate-mirroring Flush override is the follow-up for that. public override AudioBuffer Process(AudioProcessContext context) { if (Inputs.Count != 1) diff --git a/src/Beutl.Engine/Audio/Graph/Nodes/SourceNode.cs b/src/Beutl.Engine/Audio/Graph/Nodes/SourceNode.cs index 12b7c86498..c6c0f26872 100644 --- a/src/Beutl.Engine/Audio/Graph/Nodes/SourceNode.cs +++ b/src/Beutl.Engine/Audio/Graph/Nodes/SourceNode.cs @@ -17,6 +17,8 @@ public override AudioBuffer Process(AudioProcessContext context) var resource = Source.Value.Resource; var sampleCount = context.GetSampleCount(); + // Always emits stereo, so a later Flush silence buffer must match. + RecordProcessedChannelCount(2); var buffer = new AudioBuffer(context.SampleRate, 2, sampleCount); // An unloaded or failed-to-open source has SampleRate == 0 and is unreadable — return silence diff --git a/src/Beutl.Engine/Audio/Graph/Nodes/SpeedNode.cs b/src/Beutl.Engine/Audio/Graph/Nodes/SpeedNode.cs index 0282d72d24..9c1c8ba22f 100644 --- a/src/Beutl.Engine/Audio/Graph/Nodes/SpeedNode.cs +++ b/src/Beutl.Engine/Audio/Graph/Nodes/SpeedNode.cs @@ -12,18 +12,110 @@ public sealed class SpeedNode : AudioNode private SpeedProcessor? _processor; private int _lastSampleRate; private List? _upstreamSnapshot; + private bool _mappingInvalidated; private readonly SpeedIntegrator _integrator; public SpeedNode() { - _integrator = new SpeedIntegrator(0, () => _processor = null); + _integrator = new SpeedIntegrator(0, () => _mappingInvalidated = true); } public IProperty? Speed { get; set; } public override AudioBuffer Process(AudioProcessContext context) + => ProcessCore(context, draining: false); + + public override AudioBuffer Flush(AudioProcessContext context) + => ProcessCore(context, draining: true); + + /// + /// Converts upstream latency from source-domain samples to the output-domain samples that a caller + /// must reserve to drain it through this speed mapping. Static speed uses its current value; + /// keyframed speed uses each easing's conservative output range to bound every interval. + /// + public override int GetTotalLatencySamples(int sampleRate) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(sampleRate); + + int upstreamLatency = 0; + foreach (AudioNode input in Inputs) + { + upstreamLatency = Math.Max(upstreamLatency, input.GetTotalLatencySamples(sampleRate)); + } + + if (upstreamLatency == 0) + return 0; + if (upstreamLatency == int.MaxValue) + return int.MaxValue; + + if (!TryGetMinimumSpeedFactor(out double minimumSpeed) + || !double.IsFinite(minimumSpeed) + || minimumSpeed <= 0) + { + // A stopped or invalid speed has no finite output-domain duration in which every upstream + // sample is consumed. Saturate rather than under-report and silently truncate a held tail. + return int.MaxValue; + } + + double scaled = Math.Ceiling(upstreamLatency / minimumSpeed); + return scaled >= int.MaxValue ? int.MaxValue : (int)scaled; + } + + private bool TryGetMinimumSpeedFactor(out double minimumSpeed) { + IAnimation? speedAnimation = Speed?.Animation; + if (speedAnimation is null) + { + minimumSpeed = (Speed?.CurrentValue ?? 100f) / 100d; + return true; + } + + if (speedAnimation is not KeyFrameAnimation animation + || animation.KeyFrames.Count == 0) + { + minimumSpeed = default; + return false; + } + + if (animation.KeyFrames[0] is not KeyFrame first + || !float.IsFinite(first.Value)) + { + minimumSpeed = default; + return false; + } + + float minimumPercent = first.Value; + var previous = first; + for (int i = 1; i < animation.KeyFrames.Count; i++) + { + if (animation.KeyFrames[i] is not KeyFrame next + || !float.IsFinite(next.Value) + || !next.Easing.TryGetOutputRange(out float easingMinimum, out float easingMaximum) + || !float.IsFinite(easingMinimum) + || !float.IsFinite(easingMaximum) + || easingMinimum > easingMaximum) + { + minimumSpeed = default; + return false; + } + + float delta = next.Value - previous.Value; + float intervalMinimum = delta >= 0 + ? previous.Value + easingMinimum * delta + : previous.Value + easingMaximum * delta; + minimumPercent = Math.Min(minimumPercent, Math.Min(next.Value, intervalMinimum)); + previous = next; + } + + minimumSpeed = minimumPercent / 100d; + return true; + } + + private AudioBuffer ProcessCore(AudioProcessContext context, bool draining) + { + ArgumentNullException.ThrowIfNull(context); + if (Inputs.Count != 1) throw new InvalidOperationException("Variable speed node requires exactly one input."); @@ -49,27 +141,59 @@ public override AudioBuffer Process(AudioProcessContext context) if (animation == null) { - return ProcessStaticSpeed(context, expectedOutputSampleCount); + AudioBuffer result = ProcessStaticSpeed( + context, + expectedOutputSampleCount, + draining, + forceReanchor: _mappingInvalidated && !draining); + if (!draining) + _mappingInvalidated = false; + return result; } - return ProcessAnimatedSpeed(context, expectedOutputSampleCount); + AudioBuffer animatedResult = ProcessAnimatedSpeed( + context, + expectedOutputSampleCount, + draining, + forceReanchor: _mappingInvalidated && !draining); + if (!draining) + _mappingInvalidated = false; + return animatedResult; } - private AudioBuffer ProcessStaticSpeed(AudioProcessContext context, int expectedOutputSampleCount) + private AudioBuffer ProcessStaticSpeed( + AudioProcessContext context, + int expectedOutputSampleCount, + bool draining, + bool forceReanchor) { float speed = (Speed?.CurrentValue ?? 100f) / 100f; // If speed is 1.0, use normal processing - if (Math.Abs(speed - 1.0f) < float.Epsilon) + if (Math.Abs(speed - 1.0f) < float.Epsilon + && (!draining || _processor!.CanPassThroughDrain)) { - return Inputs[0].Process(context); + AudioBuffer result = draining + ? Inputs[0].Flush(context) + : Inputs[0].Process(context); + _processor!.TrackPassthrough(context, expectedOutputSampleCount); + return result; } // The processor streams the source continuously, deriving the read range itself so the // resampler is never re-seeked mid-stream. - return _processor!.ProcessBuffer(context, speed, expectedOutputSampleCount); + return _processor!.ProcessBuffer( + context, + speed, + expectedOutputSampleCount, + draining, + forceReanchor); } - private AudioBuffer ProcessAnimatedSpeed(AudioProcessContext context, int expectedOutputSampleCount) + private AudioBuffer ProcessAnimatedSpeed( + AudioProcessContext context, + int expectedOutputSampleCount, + bool draining, + bool forceReanchor) { var animation = Speed?.Animation!; var keyFrameAnimation = (KeyFrameAnimation)animation; @@ -110,7 +234,12 @@ private AudioBuffer ProcessAnimatedSpeed(AudioProcessContext context, int expect // sourceStartTime only seeds the read cursor on the first chunk / after a seek; context // supplies the sampler and original time range for the per-read sub-contexts. return _processor!.ProcessBufferWithVariableSpeed( - context, speeds, expectedOutputSampleCount, sourceStartTime.TotalSeconds); + context, + speeds, + expectedOutputSampleCount, + sourceStartTime.TotalSeconds, + draining, + forceReanchor); } finally { @@ -180,6 +309,7 @@ private sealed class SpeedProcessor private readonly int _channels; private readonly SpeedNode _speedNode; private readonly WdlResampler _rs; + private ResamplingMode _resamplingMode; private float _currentSpeed = 1.0f; // Continuous-streaming state, persisted across chunks. The resampler retains filter history @@ -188,6 +318,7 @@ private sealed class SpeedProcessor private long _srcReadPos; // absolute next source sample to feed, in the source timeline private double _nextOutputStart; // expected output start (seconds) of the next contiguous chunk private bool _initialized; + private bool _sourceCursorMatchesOutputTimeline; public SpeedProcessor(int sampleRate, int channels, SpeedNode speedNode) { @@ -202,6 +333,37 @@ public SpeedProcessor(int sampleRate, int channels, SpeedNode speedNode) _rs.SetRates(sampleRate, sampleRate); } + public bool CanPassThroughDrain => !_initialized || _sourceCursorMatchesOutputTimeline; + + public void TrackPassthrough(AudioProcessContext context, int sampleCount) + { + double outputStart = context.TimeRange.Start.TotalSeconds; + _rs.Reset(); + _srcReadPos = (long)Math.Round(outputStart * _sampleRate) + sampleCount; + _nextOutputStart = outputStart + (double)sampleCount / _sampleRate; + _currentSpeed = 1f; + _resamplingMode = ResamplingMode.Passthrough; + _initialized = true; + _sourceCursorMatchesOutputTimeline = true; + } + + private void ConfigureStaticResampling(float speed) + { + _rs.SetRates(_sampleRate, _sampleRate / speed); + _rs.SetFilterParms(); + _currentSpeed = speed; + _resamplingMode = ResamplingMode.Static; + } + + private void ConfigureVariableResampling(double speed) + { + _rs.SetRates(_sampleRate, _sampleRate / speed); + float cutoff = 0.97f / (float)speed; + _rs.SetFilterParms(cutoff, 0.707f); + _currentSpeed = (float)speed; + _resamplingMode = ResamplingMode.Variable; + } + // Decides whether this chunk continues the stream or is a seek; on a seek the resampler is // reset and the read cursor re-anchored to the computed source start. forceReanchor lets the // caller declare a discontinuity the output-time comparison cannot see (e.g. a static-speed @@ -224,17 +386,27 @@ private bool BeginStream(double outputStartSeconds, double sourceStartSeconds, b // Reads exactly the requested source samples, continuing from the persistent cursor so the // stream never jumps between chunks. Advances the cursor by what was actually produced (short // only at end-of-source) and returns that count. - private int Read(float[] buffer, int interleavedOffset, int count, AudioProcessContext context) + private int Read( + float[] buffer, + int interleavedOffset, + int count, + AudioProcessContext context, + bool draining) { if (count <= 0) return 0; - // _srcReadPos is whole samples, but reaches the input as a TimeSpan that the source - // truncates back (e.g. (int)(seconds * sampleRate)). Bias by half a sample so the - // round-trip lands on _srcReadPos rather than occasionally _srcReadPos - 1. + // Derive every timestamp from the exact sample cursor. Accumulating representable + // TimeSpan durations loses fractional ticks at rates such as 44.1 kHz and eventually + // repeats a source sample. Round up to the first representable tick inside this sample: + // a source that truncates TimeSpan lands on _srcReadPos, while the new range remains + // within one tick of the previous end so stateful upstream nodes see a continuation. + long sourceStartTicks = (long)Math.Ceiling( + _srcReadPos * (double)TimeSpan.TicksPerSecond / _sampleRate); + TimeSpan sourceStart = TimeSpan.FromTicks(sourceStartTicks); var range = new TimeRange( - TimeSpan.FromSeconds((_srcReadPos + 0.5) / _sampleRate), - TimeSpan.FromSeconds(count / (double)_sampleRate)); + sourceStart, + AudioProcessContext.GetDurationForSampleCount(count, _sampleRate)); var subContext = new AudioProcessContext( range, _sampleRate, @@ -244,7 +416,9 @@ private int Read(float[] buffer, int interleavedOffset, int count, AudioProcessC // Read consumes the child buffer fully into the interleaved span and never returns it, so // dispose its pooled MemoryPool lease here; otherwise every resampler iteration leaks // one input buffer (matches the disposal contract the sibling audio nodes already honor). - using var result = _speedNode.Inputs[0].Process(subContext); + using AudioBuffer result = draining + ? _speedNode.Inputs[0].Flush(subContext) + : _speedNode.Inputs[0].Process(subContext); var leftData = result.GetChannelData(0); var rightData = result.GetChannelData(1); int samplesToRead = Math.Min(count, result.SampleCount); @@ -258,7 +432,12 @@ private int Read(float[] buffer, int interleavedOffset, int count, AudioProcessC return samplesToRead; } - public AudioBuffer ProcessBuffer(AudioProcessContext context, float speed, int expectedOut) + public AudioBuffer ProcessBuffer( + AudioProcessContext context, + float speed, + int expectedOut, + bool draining, + bool forceReanchor) { double outputStart = context.TimeRange.Start.TotalSeconds; @@ -266,16 +445,22 @@ public AudioBuffer ProcessBuffer(AudioProcessContext context, float speed, int e // BeginStream's output-time comparison cannot see: output stays contiguous, but _srcReadPos // (tracking the old speed) no longer maps to outputStart. Force a re-anchor to outputStart * // speed. _initialized gates this so the first-chunk anchor still uses the normal seek path. - bool speedChanged = _initialized && Math.Abs(_currentSpeed - speed) > 1e-4f; - bool seek = BeginStream(outputStart, outputStart * speed, speedChanged); + bool configurationChanged = _resamplingMode != ResamplingMode.Static + || Math.Abs(_currentSpeed - speed) > 1e-4f; + bool seek = BeginStream( + outputStart, + outputStart * speed, + forceReanchor: _initialized + && !draining + && (configurationChanged || forceReanchor)); // Re-set the rate after a seek (resampler just reset) or when the constant speed changes. // Never Reset() outside a seek: that zero-fills filter history and silences a continuous - // stream — a static-speed change is promoted to a seek above, so its reset is intentional. - if (seek || speedChanged) + // stream. Normal processing promotes a static-speed change to a seek; draining instead + // keeps the cursor retained at the upstream clip end and changes only the resampling rate. + if (seek || configurationChanged) { - _currentSpeed = speed; - _rs.SetRates(_sampleRate, _sampleRate / speed); + ConfigureStaticResampling(speed); } var output = new AudioBuffer(_sampleRate, _channels, expectedOut); @@ -287,7 +472,7 @@ public AudioBuffer ProcessBuffer(AudioProcessContext context, float speed, int e while (framesDone < expectedOut) { int want = _rs.ResamplePrepare(expectedOut - framesDone, _channels, out float[] inBuf, out int inOff); - int got = Read(inBuf, inOff, want, context); + int got = Read(inBuf, inOff, want, context, draining); int made = _rs.ResampleOut(dst, framesDone * _channels, got, expectedOut - framesDone, _channels); @@ -302,6 +487,7 @@ public AudioBuffer ProcessBuffer(AudioProcessContext context, float speed, int e WriteAndPad(output, dst, framesDone, expectedOut); // Advance only on full success. _nextOutputStart = outputStart + (double)expectedOut / _sampleRate; + _sourceCursorMatchesOutputTimeline = false; return output; } catch @@ -313,10 +499,18 @@ public AudioBuffer ProcessBuffer(AudioProcessContext context, float speed, int e } public AudioBuffer ProcessBufferWithVariableSpeed( - AudioProcessContext context, ReadOnlySpan speedCurve, int expectedOut, double sourceStartSeconds) + AudioProcessContext context, + ReadOnlySpan speedCurve, + int expectedOut, + double sourceStartSeconds, + bool draining, + bool forceReanchor) { double outputStart = context.TimeRange.Start.TotalSeconds; - BeginStream(outputStart, sourceStartSeconds); + BeginStream( + outputStart, + sourceStartSeconds, + forceReanchor: _initialized && !draining && forceReanchor); var output = new AudioBuffer(_sampleRate, _channels, expectedOut); try @@ -336,12 +530,10 @@ public AudioBuffer ProcessBufferWithVariableSpeed( sumSpeed += speedCurve[framesDone + i]; double vAvg = sumSpeed / framesThis; - _rs.SetRates(_sampleRate, _sampleRate / vAvg); - float cutoff = 0.97f / (float)vAvg; // vAvg > 1 lowers Nyquist to avoid aliasing - _rs.SetFilterParms(cutoff, 0.707f); + ConfigureVariableResampling(vAvg); int want = _rs.ResamplePrepare(framesThis, _channels, out float[] inBuf, out int inOff); - int got = Read(inBuf, inOff, want, context); + int got = Read(inBuf, inOff, want, context, draining); int made = _rs.ResampleOut(dst, framesDone * _channels, got, framesThis, _channels); @@ -354,6 +546,7 @@ public AudioBuffer ProcessBufferWithVariableSpeed( WriteAndPad(output, dst, framesDone, expectedOut); // Advance only on full success. _nextOutputStart = outputStart + (double)expectedOut / _sampleRate; + _sourceCursorMatchesOutputTimeline = false; return output; } catch @@ -363,6 +556,14 @@ public AudioBuffer ProcessBufferWithVariableSpeed( } } + private enum ResamplingMode + { + Unconfigured, + Passthrough, + Static, + Variable, + } + // De-interleaves the produced frames into the output and pads any shortfall (source exhausted) // with the last value to avoid a hard edge. private void WriteAndPad(AudioBuffer output, float[] dst, int framesDone, int expectedOut) diff --git a/src/Beutl.Engine/Audio/SoundGroup.cs b/src/Beutl.Engine/Audio/SoundGroup.cs index bab43a12af..a9291e504c 100644 --- a/src/Beutl.Engine/Audio/SoundGroup.cs +++ b/src/Beutl.Engine/Audio/SoundGroup.cs @@ -95,6 +95,9 @@ public override void Compose(AudioContext context, Sound.Resource resource) var shiftNode = context.CreateShiftNode(TimeRange.Start); context.Connect(outputNode, shiftNode); context.Connect(shiftNode, mixerNode); + // A child that ends before the group lets the mixer skip its already-recovered tail + // instead of re-emitting it late during the group's terminal flush. + mixerNode.SetBranchEndTime(shiftNode, original.TimeRange.End - TimeRange.Start); } } diff --git a/src/Beutl.Engine/Audio/SourceSound.Thumbnails.cs b/src/Beutl.Engine/Audio/SourceSound.Thumbnails.cs index f4a62f4433..9c8ab60fcc 100644 --- a/src/Beutl.Engine/Audio/SourceSound.Thumbnails.cs +++ b/src/Beutl.Engine/Audio/SourceSound.Thumbnails.cs @@ -113,7 +113,11 @@ public async IAsyncEnumerable GetWaveformChunksAsync( var cacheThreshold = TimeSpan.FromSeconds(chunkDurationSecs * 0.5); using var composer = new Composer { SampleRate = sampleRate }; - var frame = new CompositionFrame([resource], TimeRange, default); + var frame = new CompositionFrame( + [resource], + TimeRange, + default, + new CompositionEligibility([resource.GetOriginal()])); for (int chunkIndex = 0; chunkIndex < chunkCount; chunkIndex++) { diff --git a/src/Beutl.Engine/Composition/CompositionEligibility.cs b/src/Beutl.Engine/Composition/CompositionEligibility.cs new file mode 100644 index 0000000000..d6908df74d --- /dev/null +++ b/src/Beutl.Engine/Composition/CompositionEligibility.cs @@ -0,0 +1,38 @@ +using System.Collections.Immutable; +using Beutl.Engine; + +namespace Beutl.Composition; + +/// +/// Captures the original objects that are currently eligible for a composition target, independently +/// of whether their time ranges intersect the evaluated frame. +/// +/// +/// Original objects are identity tokens only. Membership always uses reference equality so a plugin +/// type overriding cannot alias another object. +/// +public readonly struct CompositionEligibility +{ + private readonly ImmutableHashSet? _objects; + + /// Creates an immutable eligibility snapshot from original object identities. + /// Objects eligible for the frame's composition target. + public CompositionEligibility(IEnumerable objects) + { + ArgumentNullException.ThrowIfNull(objects); + _objects = objects.ToImmutableHashSet(ReferenceEqualityComparer.Instance); + } + + /// Gets a snapshot in which no objects are eligible. + public static CompositionEligibility Empty => default; + + /// Gets the number of eligible object identities. + public int Count => _objects?.Count ?? 0; + + /// Determines whether the snapshot contains the exact instance. + public bool Contains(EngineObject obj) + { + ArgumentNullException.ThrowIfNull(obj); + return _objects?.Contains(obj) ?? false; + } +} diff --git a/src/Beutl.Engine/Composition/CompositionFrame.cs b/src/Beutl.Engine/Composition/CompositionFrame.cs index 1b25c35893..b6a111ea8c 100644 --- a/src/Beutl.Engine/Composition/CompositionFrame.cs +++ b/src/Beutl.Engine/Composition/CompositionFrame.cs @@ -4,7 +4,17 @@ namespace Beutl.Composition; +/// Captures resources and any requested target-eligibility snapshot for one evaluated range. +/// Resources whose time ranges intersect the evaluated range. +/// The evaluated composition range. +/// The output pixel size. +/// +/// Original objects currently eligible for the composition target, including objects outside +/// , or when the evaluator did not capture an +/// eligibility snapshot. +/// public readonly record struct CompositionFrame( ImmutableArray Objects, TimeRange Time, - PixelSize Size); + PixelSize Size, + CompositionEligibility? Eligibility); diff --git a/src/Beutl.Engine/Graphics/AudioVisualizers/AudioVisualizerDrawable.cs b/src/Beutl.Engine/Graphics/AudioVisualizers/AudioVisualizerDrawable.cs index 27f6a74e92..6722878a79 100644 --- a/src/Beutl.Engine/Graphics/AudioVisualizers/AudioVisualizerDrawable.cs +++ b/src/Beutl.Engine/Graphics/AudioVisualizers/AudioVisualizerDrawable.cs @@ -162,7 +162,11 @@ private void EnsureSamplesComposed(TimeSpan targetStart, TimeSpan targetDuration _frameObjectsSource = _source; } - var frame = new CompositionFrame(_frameObjects, sound.TimeRange, default); + var frame = new CompositionFrame( + _frameObjects, + sound.TimeRange, + default, + new CompositionEligibility([sound])); AudioBuffer? buffer = _composer.Compose(targetRange, frame); try diff --git a/src/Beutl.ProjectSystem/ProjectSystem/SceneSound.cs b/src/Beutl.ProjectSystem/ProjectSystem/SceneSound.cs index 411d44dea2..f7a8669cc0 100644 --- a/src/Beutl.ProjectSystem/ProjectSystem/SceneSound.cs +++ b/src/Beutl.ProjectSystem/ProjectSystem/SceneSound.cs @@ -114,6 +114,12 @@ partial void PostDispose(bool disposing) private sealed class SceneNode(Resource? resource) : AudioNode { + // Known limitation: this leaf composes the referenced scene through its own Composer but inherits + // the zero-latency GetLatencySamples/Flush defaults, so an outer ClipNode reserves no drain for a + // lookahead limiter living inside the referenced scene. When a SceneSound clip cuts the scene + // before an inner limiter-bearing sound ends, that inner tail is dropped at the boundary. + // Surfacing it needs SceneNode to aggregate its internal graph's latency and flush that graph — a + // follow-up that reaches into the referenced scene's Composer. internal Resource? _resource = resource; private Composer? _composer; diff --git a/src/Beutl.ProjectSystem/SceneCompositor.cs b/src/Beutl.ProjectSystem/SceneCompositor.cs index 9f0f1a3792..02cdf1fcd4 100644 --- a/src/Beutl.ProjectSystem/SceneCompositor.cs +++ b/src/Beutl.ProjectSystem/SceneCompositor.cs @@ -95,11 +95,16 @@ public CompositionFrame EvaluateGraphics(TimeSpan time) allResources.AddRange(flow.Span); } - return new CompositionFrame([.. allResources], new(time, TimeSpan.FromTicks(1)), Scene.FrameSize); + return new CompositionFrame( + [.. allResources], + new(time, TimeSpan.FromTicks(1)), + Scene.FrameSize, + null); } public CompositionFrame EvaluateAudio(TimeRange timeRange) { + CompositionEligibility eligibility = CollectEligibility(CompositionTarget.Audio); using var currentElements = new PooledList(); SortLayers(timeRange, currentElements, CompositionTarget.Audio); @@ -118,7 +123,22 @@ public CompositionFrame EvaluateAudio(TimeRange timeRange) allResources.AddRange(flow.Span); } - return new CompositionFrame([.. allResources], timeRange, Scene.FrameSize); + return new CompositionFrame([.. allResources], timeRange, Scene.FrameSize, eligibility); + } + + private CompositionEligibility CollectEligibility(CompositionTarget target) + { + using var eligibleObjects = new PooledList(); + LayerSnapshot snapshot = GetLayerSnapshot(); + + foreach (Element item in Scene.Children) + { + if (!item.IsEnabled) continue; + if (ShouldSkipLayer(item.ZIndex, target, snapshot.HasSolo, snapshot.ByZIndex)) continue; + item.CollectObjects(target, eligibleObjects); + } + + return new CompositionEligibility(eligibleObjects); } private void CollectResourcesFromElement( diff --git a/tests/Beutl.UnitTests/Engine/Animation/EasingOutputRangeTests.cs b/tests/Beutl.UnitTests/Engine/Animation/EasingOutputRangeTests.cs new file mode 100644 index 0000000000..b1ad57a903 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Animation/EasingOutputRangeTests.cs @@ -0,0 +1,74 @@ +using Beutl.Animation.Easings; + +namespace Beutl.UnitTests.Engine.Animation; + +[TestFixture] +public class EasingOutputRangeTests +{ + [Test] + public void BuiltInEasings_ReportConservativeFiniteRanges() + { + Type[] easingTypes = typeof(Easing).Assembly.GetTypes() + .Where(type => type.IsPublic + && !type.IsAbstract + && typeof(Easing).IsAssignableFrom(type) + && type.GetConstructor(Type.EmptyTypes) is not null) + .ToArray(); + + Assert.That(easingTypes, Is.Not.Empty); + + foreach (Type easingType in easingTypes) + { + var easing = (Easing)Activator.CreateInstance(easingType)!; + Assert.Multiple(() => + { + Assert.That( + easing.TryGetOutputRange(out float minimum, out float maximum), + Is.True, + $"{easingType.Name} must publish a conservative range."); + Assert.That(minimum, Is.Not.NaN, $"{easingType.Name} minimum"); + Assert.That(maximum, Is.Not.NaN, $"{easingType.Name} maximum"); + Assert.That(float.IsInfinity(minimum), Is.False, $"{easingType.Name} minimum"); + Assert.That(float.IsInfinity(maximum), Is.False, $"{easingType.Name} maximum"); + Assert.That(minimum, Is.LessThanOrEqualTo(maximum), easingType.Name); + + const int sampleCount = 10_000; + for (int i = 0; i <= sampleCount; i++) + { + float progress = i / (float)sampleCount; + float value = easing.Ease(progress); + Assert.That( + value, + Is.InRange(minimum - 1e-5f, maximum + 1e-5f), + $"{easingType.Name} at progress {progress}"); + } + }); + } + } + + [Test] + public void SplineEasing_RangeContainsControlPointHull() + { + var easing = new SplineEasing(0.25f, -2f, 0.75f, 3f); + + Assert.That(easing.TryGetOutputRange(out float minimum, out float maximum), Is.True); + Assert.Multiple(() => + { + Assert.That(minimum, Is.EqualTo(-2f)); + Assert.That(maximum, Is.EqualTo(3f)); + }); + } + + [Test] + public void CustomEasing_DefaultsToUnknownRange() + { + var easing = new UnknownRangeEasing(); + + Assert.That(easing.TryGetOutputRange(out _, out _), Is.False); + } + + private sealed class UnknownRangeEasing : Easing + { + public override float Ease(float progress) => progress; + } +} diff --git a/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs b/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs new file mode 100644 index 0000000000..3ed243a499 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs @@ -0,0 +1,1467 @@ +using System.Collections.Immutable; + +using Beutl.Animation; +using Beutl.Animation.Easings; +using Beutl.Audio; +using Beutl.Audio.Composing; +using Beutl.Audio.Effects; +using Beutl.Audio.Effects.Equalizer; +using Beutl.Audio.Graph; +using Beutl.Audio.Graph.Nodes; +using Beutl.Composition; +using Beutl.Engine; +using Beutl.Logging; +using Beutl.Media; +using Microsoft.Extensions.Logging; + +using static Beutl.UnitTests.Engine.Audio.AudioTestBuffers; + +namespace Beutl.UnitTests.Engine.Audio; + +[TestFixture] +public class AudioLatencyCompensationTests +{ + private const int SampleRate = 48000; + + [OneTimeSetUp] + public void OneTimeSetUp() + { + if (Log.LoggerFactory is null) + { + Log.LoggerFactory = LoggerFactory.Create(_ => { }); + } + } + + private static int LookaheadSamples(float lookaheadMs, int sampleRate = SampleRate) + => (int)(lookaheadMs / 1000f * sampleRate); + + // Threshold high enough that a unit-scale signal never trips limiting, so the limiter is a pure + // lookahead delay and the tail-recovery math is exact. + private static LimiterNode CreateTransparentLimiter(float lookaheadMs) + => new() + { + Threshold = Property.CreateAnimatable(LimiterParameters.MaxThresholdDb), + Release = Property.CreateAnimatable(LimiterParameters.DefaultReleaseMs), + Lookahead = Property.CreateAnimatable(lookaheadMs), + MakeupGain = Property.CreateAnimatable(0f), + }; + + private static AudioProcessContext Context(TimeSpan start, int sampleCount, int sampleRate = SampleRate) + { + var duration = TimeSpan.FromSeconds((double)sampleCount / sampleRate); + return new AudioProcessContext(new TimeRange(start, duration), sampleRate, new AnimationSampler(), null); + } + + [Test] + public void Flush_RecoversTheTailHeldInTheDelayLine() + { + const float lookaheadMs = 5f; + const int sampleCount = 4096; + int L = LookaheadSamples(lookaheadMs); + + // A ramp makes every input index identifiable in the (delayed) output. + using var input = CreateBuffer(2, sampleCount, (_, i) => 0.25f * MathF.Sin(2f * MathF.PI * 220f * i / SampleRate)); + + using var node = CreateTransparentLimiter(lookaheadMs); + node.AddInput(new BufferReplayNode(input)); + + // Process the clip: output[i] = input[i - L]; the last L inputs stay stuck in the delay line. + using var processed = node.Process(Context(TimeSpan.Zero, sampleCount)); + + // Flush, contiguous with the processed chunk, must emit those last L inputs. + var flushDuration = TimeSpan.FromSeconds((double)sampleCount / SampleRate); + using var tail = node.Flush(Context(flushDuration, sampleCount)); + + var inData = input.GetChannelData(0); + var tailData = tail.GetChannelData(0); + for (int k = 0; k < L; k++) + { + // The j-th flushed sample carries input[sampleCount - L + k]. + Assert.That(tailData[k], Is.EqualTo(inData[sampleCount - L + k]).Within(1e-5f), + $"Flushed tail sample {k} must equal the input sample lost off the processed tail."); + } + } + + [Test] + public void ProcessThenFlush_ConcatenatesToTheFullDelayedInput_NoLoss() + { + const float lookaheadMs = 5f; + const int sampleCount = 2048; + int L = LookaheadSamples(lookaheadMs); + + using var input = CreateBuffer(2, sampleCount, (_, i) => 0.25f * MathF.Sin(2f * MathF.PI * 330f * i / SampleRate)); + + using var node = CreateTransparentLimiter(lookaheadMs); + node.AddInput(new BufferReplayNode(input)); + + using var processed = node.Process(Context(TimeSpan.Zero, sampleCount)); + using var tail = node.Flush(Context(TimeSpan.FromSeconds((double)sampleCount / SampleRate), sampleCount)); + + // processed[i] = input[i-L] for i>=L; tail[k] = input[sampleCount-L+k]. Concatenating processed + // then the first L of tail reproduces input delayed by L with NO samples dropped. + var inData = input.GetChannelData(0); + var procData = processed.GetChannelData(0); + var tailData = tail.GetChannelData(0); + for (int i = L; i < sampleCount; i++) + { + Assert.That(procData[i], Is.EqualTo(inData[i - L]).Within(1e-5f)); + } + for (int k = 0; k < L; k++) + { + Assert.That(tailData[k], Is.EqualTo(inData[sampleCount - L + k]).Within(1e-5f)); + } + } + + [Test] + public void Flush_DefaultPassThrough_ReturnsSilence() + { + // A node with no latency and a leaf input drains to silence (nothing held). + using var gain = new GainNode { Gain = Property.CreateAnimatable(100f) }; + using var buffer = CreateConstantBuffer(0.3f, 64); + gain.AddInput(new BufferReplayNode(buffer)); + + using var tail = gain.Flush(Context(TimeSpan.FromSeconds(64.0 / SampleRate), 32)); + + var data = tail.GetChannelData(0); + for (int i = 0; i < tail.SampleCount; i++) + { + Assert.That(data[i], Is.EqualTo(0f), "A latency-free chain flushes to silence."); + } + } + + [Test] + public void Flush_DoesNotResetTheLimiter_StaysContiguousWithProcess() + { + const float lookaheadMs = 5f; + const int sampleCount = 1024; + int L = LookaheadSamples(lookaheadMs); + + using var input = CreateBuffer(2, sampleCount, (_, i) => 0.25f * MathF.Sin(2f * MathF.PI * 440f * i / SampleRate)); + using var node = CreateTransparentLimiter(lookaheadMs); + node.AddInput(new BufferReplayNode(input)); + + using var processed = node.Process(Context(TimeSpan.Zero, sampleCount)); + // If Flush were treated as a discontinuity, the limiter would Reset() and emit silence (cold + // delay line) instead of the retained tail. A non-silent tail proves contiguity held. + using var tail = node.Flush(Context(TimeSpan.FromSeconds((double)sampleCount / SampleRate), sampleCount)); + + var tailData = tail.GetChannelData(0); + bool anyNonZero = false; + for (int k = 0; k < L; k++) + { + if (MathF.Abs(tailData[k]) > 1e-6f) { anyNonZero = true; break; } + } + + Assert.That(anyNonZero, Is.True, "Flush stayed contiguous; the delay line drained real audio, not a post-reset silence."); + } + + [Test] + public void ClipNode_TerminalWindow_AppendsRecoveredTail() + { + const float lookaheadMs = 5f; + int L = LookaheadSamples(lookaheadMs); + const int clipSamples = 4096; + var clipDuration = TimeSpan.FromSeconds((double)clipSamples / SampleRate); + + // Source feeds the clip-local range; a sine so the recovered tail is identifiable and non-zero. + var source = new RangeSineNode(SampleRate); + using var limiter = CreateTransparentLimiter(lookaheadMs); + limiter.AddInput(source); + + using var clip = new ClipNode { Start = TimeSpan.Zero, Duration = clipDuration }; + clip.AddInput(limiter); + + // A window that extends L samples past the clip end leaves trailing capacity, so AppendFlushedTail + // drains the limiter's held tail into [clipSamples, clipSamples + L) — the region inline + // processing drops. A window covering the clip exactly has capacity == 0 and never drains, so the + // assertion below would pass on the main slice even if recovery were a no-op. + using var output = clip.Process(Context(TimeSpan.Zero, clipSamples + L)); + + var data = output.GetChannelData(0); + bool tailNonZero = false; + for (int i = clipSamples; i < clipSamples + L; i++) + { + if (MathF.Abs(data[i]) > 1e-5f) { tailNonZero = true; break; } + } + + Assert.That(tailNonZero, Is.True, + "The limiter's held tail, normally lost in the delay line, is recovered into the trailing L samples."); + } + + [Test] + public void ClipNode_ZeroLookahead_IsNoOp() + { + const int clipSamples = 2048; + const int refOffset = 256; + var clipDuration = TimeSpan.FromSeconds((double)clipSamples / SampleRate); + + // No-latency chain: with L==0 the terminal-window drain must change nothing. + var sourceA = new RangeSineNode(SampleRate); + using var clipA = new ClipNode { Start = TimeSpan.Zero, Duration = clipDuration }; + clipA.AddInput(sourceA); + using var withDrain = clipA.Process(Context(TimeSpan.Zero, clipSamples)); + + var sourceB = new RangeSineNode(SampleRate); + using var clipB = new ClipNode { Start = TimeSpan.Zero, Duration = clipDuration }; + clipB.AddInput(sourceB); + // The reference stops short of the clip end, so its terminal-drain branch never runs; clipA + // reaches the end and does enter it. Comparing the overlap gives the assertion discriminating + // power: if the L==0 terminal path perturbed the main slice, only clipA would differ. + using var reference = clipB.Process(Context(TimeSpan.Zero, clipSamples - refOffset)); + + var a = withDrain.GetChannelData(0); + var b = reference.GetChannelData(0); + for (int i = 0; i < clipSamples - refOffset; i++) + { + Assert.That(a[i], Is.EqualTo(b[i]).Within(1e-6f), $"L==0 drain must not perturb sample {i}."); + } + } + + [Test] + public void Flush_AppliesDownstreamProcessing_ToTheTail() + { + const float lookaheadMs = 5f; + const int sampleCount = 2048; + int L = LookaheadSamples(lookaheadMs); + + // leaf -> Limiter(delay) -> Gain(0.5). The recovered tail must be scaled by the downstream + // gain, not handed back raw — the regression guard for the bypassing default Flush. + using var input = CreateBuffer(2, sampleCount, (_, i) => 0.25f * MathF.Sin(2f * MathF.PI * 330f * i / SampleRate)); + using var limiter = CreateTransparentLimiter(lookaheadMs); + limiter.AddInput(new BufferReplayNode(input)); + using var gain = new GainNode { Gain = Property.CreateAnimatable(50f) }; // 50% = 0.5x + gain.AddInput(limiter); + + using var processed = gain.Process(Context(TimeSpan.Zero, sampleCount)); + using var tail = gain.Flush(Context(TimeSpan.FromSeconds((double)sampleCount / SampleRate), sampleCount)); + + var inData = input.GetChannelData(0); + var tailData = tail.GetChannelData(0); + for (int k = 0; k < L; k++) + { + // Tail carries input[sampleCount-L+k] (limiter delay) scaled by 0.5 (downstream gain). + Assert.That(tailData[k], Is.EqualTo(inData[sampleCount - L + k] * 0.5f).Within(1e-5f), + $"Flushed tail sample {k} must have the downstream gain applied."); + } + } + + [TestCase(false)] + [TestCase(true)] + public void SpeedNode_Flush_MapsTheDrainToTheUpstreamSourceTimeline(bool animated) + { + const float lookaheadMs = 5f; + const int sampleCount = 4096; + int L = LookaheadSamples(lookaheadMs); + + var source = new RangeSineNode(SampleRate); + using var limiter = CreateTransparentLimiter(lookaheadMs); + limiter.AddInput(source); + var speedProperty = Property.CreateAnimatable(50f); + if (animated) + { + speedProperty.Animation = new KeyFrameAnimation + { + KeyFrames = + { + new KeyFrame + { + KeyTime = TimeSpan.Zero, + Value = 50f, + Easing = new LinearEasing(), + }, + new KeyFrame + { + KeyTime = TimeSpan.FromSeconds(10), + Value = 50f, + Easing = new LinearEasing(), + }, + }, + }; + } + + using var speed = new SpeedNode { Speed = speedProperty }; + speed.AddInput(limiter); + + using var processed = speed.Process(Context(TimeSpan.Zero, sampleCount)); + using var tail = speed.Flush( + Context(TimeSpan.FromSeconds((double)sampleCount / SampleRate), sampleCount)); + + Assert.That(HasNonZero(tail.GetChannelData(0)[..(L * 2)]), Is.True, + "A non-unity SpeedNode must drain the upstream limiter at the resampler's source cursor, " + + "not forward the output-domain time and reset the limiter."); + } + + [TestCase(50f)] + [TestCase(200f)] + public void SpeedNode_Flush_AfterUnityProcessAndSpeedChange_UsesTrackedSourceCursor(float drainSpeed) + { + const float lookaheadMs = 5f; + const int sampleCount = 4096; + + var source = new RangeSineNode(SampleRate); + using var limiter = CreateTransparentLimiter(lookaheadMs); + limiter.AddInput(source); + var speedProperty = Property.CreateAnimatable(100f); + using var speed = new SpeedNode { Speed = speedProperty }; + speed.AddInput(limiter); + + using var processed = speed.Process(Context(TimeSpan.Zero, sampleCount)); + speedProperty.CurrentValue = drainSpeed; + using var tail = speed.Flush( + Context(TimeSpan.FromSeconds((double)sampleCount / SampleRate), sampleCount)); + + Assert.That(HasNonZero(tail.GetChannelData(0)), Is.True, + "A SpeedNode that processed at unity must retain the upstream source cursor when the " + + "speed changes before its drain."); + } + + [TestCase(50f)] + [TestCase(200f)] + public void SpeedNode_Flush_WhenSpeedChangesToUnity_KeepsTheMappedSourceCursor(float processSpeed) + { + const float lookaheadMs = 5f; + const int sampleCount = 4096; + + var source = new RangeSineNode(SampleRate); + using var limiter = CreateTransparentLimiter(lookaheadMs); + limiter.AddInput(source); + var speedProperty = Property.CreateAnimatable(processSpeed); + using var speed = new SpeedNode { Speed = speedProperty }; + speed.AddInput(limiter); + + using var processed = speed.Process(Context(TimeSpan.Zero, sampleCount)); + speedProperty.CurrentValue = 100f; + using var tail = speed.Flush( + Context(TimeSpan.FromSeconds((double)sampleCount / SampleRate), sampleCount)); + + Assert.That(HasNonZero(tail.GetChannelData(0)), Is.True, + "Switching to unity for the drain must not bypass the resampler's retained source cursor."); + } + + [TestCase(50f)] + [TestCase(200f)] + public void SpeedNode_Flush_AfterAnimatedProcessAndStaticUnityTransition_RequestsUnityRate( + float animatedSpeed) + { + const int sampleCount = 4096; + + using var input = new RecordingFlushRequestNode(); + var speedProperty = Property.CreateAnimatable(animatedSpeed); + speedProperty.Animation = ConstantSpeedAnimation(animatedSpeed); + using var speed = new SpeedNode { Speed = speedProperty }; + speed.AddInput(input); + + using var processed = speed.Process(Context(TimeSpan.Zero, sampleCount)); + speedProperty.Animation = null; + speedProperty.CurrentValue = 100f; + using var tail = speed.Flush( + Context(TimeSpan.FromSeconds((double)sampleCount / SampleRate), sampleCount)); + + Assert.That(input.TotalFlushedSamples, Is.InRange(sampleCount - 256, sampleCount + 256), + "Switching from animated speed to static unity must reconfigure the resampler to a " + + "one-source-sample-per-output-sample drain."); + Assert.That(input.FirstFlushStart, Is.Not.Null); + Assert.That(input.LastProcessedEnd, Is.Not.Null); + Assert.That( + Math.Abs((input.FirstFlushStart!.Value - input.LastProcessedEnd!.Value).Ticks), + Is.LessThanOrEqualTo(1), + "The animated-to-static transition must preserve the exact upstream source cursor; " + + "forwarding the output-domain start would reset a stateful upstream node."); + } + + [Test] + public void SpeedNode_Flush_WhenReturningToPreviousStaticSpeed_ReconfiguresAfterAnimation() + { + const int sampleCount = 4096; + var chunkDuration = TimeSpan.FromSeconds((double)sampleCount / SampleRate); + + using var input = new RecordingFlushRequestNode(); + var speedProperty = Property.CreateAnimatable(50f); + using var speed = new SpeedNode { Speed = speedProperty }; + speed.AddInput(input); + + using var staticChunk = speed.Process(Context(TimeSpan.Zero, sampleCount)); + speedProperty.Animation = ConstantSpeedAnimation(200f); + using var animatedChunk = speed.Process(Context(chunkDuration, sampleCount)); + speedProperty.Animation = null; + speedProperty.CurrentValue = 50f; + using var tail = speed.Flush(Context(chunkDuration + chunkDuration, sampleCount)); + + int expectedSourceSamples = sampleCount / 2; + Assert.That( + input.TotalFlushedSamples, + Is.InRange(expectedSourceSamples - 256, expectedSourceSamples + 256), + "Returning to the same static value used before animation must still restore the static " + + "rate and filter instead of retaining the animated 200% configuration."); + } + + [Test] + public void MixerNode_Flush_MergesBranchTails() + { + const float lookaheadMs = 5f; + const int sampleCount = 1024; + int L = LookaheadSamples(lookaheadMs); + + // Branch A holds a limiter tail; branch B is silent. The mixer flush must surface A's tail. + using var inputA = CreateBuffer(2, sampleCount, (_, i) => 0.25f * MathF.Sin(2f * MathF.PI * 440f * i / SampleRate)); + using var limiterA = CreateTransparentLimiter(lookaheadMs); + limiterA.AddInput(new BufferReplayNode(inputA)); + using var silentB = new GainNode { Gain = Property.CreateAnimatable(100f) }; + using var bufferB = CreateConstantBuffer(0f, sampleCount); + silentB.AddInput(new BufferReplayNode(bufferB)); + + using var mixer = new MixerNode(); + mixer.AddInput(limiterA); + mixer.AddInput(silentB); + + using var processed = mixer.Process(Context(TimeSpan.Zero, sampleCount)); + using var tail = mixer.Flush(Context(TimeSpan.FromSeconds((double)sampleCount / SampleRate), sampleCount)); + + var tailData = tail.GetChannelData(0); + bool anyNonZero = false; + for (int k = 0; k < L; k++) + { + if (MathF.Abs(tailData[k]) > 1e-6f) { anyNonZero = true; break; } + } + + Assert.That(anyNonZero, Is.True, "The mixer flush merged branch A's drained tail instead of returning silence."); + } + + [Test] + public void MixerNode_Flush_SkipsBranchThatEndedBeforeTheTerminalSlice() + { + const float lookaheadMs = 5f; + const int sampleCount = 1024; + int L = LookaheadSamples(lookaheadMs); + var groupEnd = TimeSpan.FromSeconds((double)sampleCount / SampleRate); + var earlyEnd = TimeSpan.FromSeconds((double)(sampleCount / 2) / SampleRate); + + // Branch A holds a real limiter tail but its clip ended at the group midpoint — its tail was + // already recovered at its own end, so the group's terminal flush must NOT re-emit it seconds + // late. Branch B runs to the group end and is silent. With A correctly skipped, the merge is + // silent; draining A unconditionally would leak its stale tail into the group pad. + using var inputA = CreateBuffer(2, sampleCount, (_, i) => 0.25f * MathF.Sin(2f * MathF.PI * 440f * i / SampleRate)); + using var limiterA = CreateTransparentLimiter(lookaheadMs); + limiterA.AddInput(new BufferReplayNode(inputA)); + using var limiterB = CreateTransparentLimiter(lookaheadMs); + using var bufferB = CreateConstantBuffer(0f, sampleCount); + limiterB.AddInput(new BufferReplayNode(bufferB)); + + using var mixer = new MixerNode(); + mixer.AddInput(limiterA); + mixer.AddInput(limiterB); + mixer.SetBranchEndTime(limiterA, earlyEnd); + mixer.SetBranchEndTime(limiterB, groupEnd); + + using var processed = mixer.Process(Context(TimeSpan.Zero, sampleCount)); + using var tail = mixer.Flush(Context(groupEnd, sampleCount)); + + var tailData = tail.GetChannelData(0); + for (int k = 0; k < L; k++) + { + Assert.That(tailData[k], Is.EqualTo(0f).Within(1e-6f), + $"Branch A ended before the terminal slice; its stale tail must not be mixed into the group pad (sample {k})."); + } + } + + [Test] + public void MixerNode_RemoveInput_KeepsBranchEndTimeAligned() + { + const float lookaheadMs = 5f; + const int sampleCount = 1024; + int L = LookaheadSamples(lookaheadMs); + var groupEnd = TimeSpan.FromSeconds((double)sampleCount / SampleRate); + var earlyEnd = TimeSpan.FromSeconds((double)(sampleCount / 2) / SampleRate); + + using var inputA = CreateConstantBuffer(0f, sampleCount); + using var limiterA = CreateTransparentLimiter(lookaheadMs); + limiterA.AddInput(new BufferReplayNode(inputA)); + using var inputB = CreateBuffer(2, sampleCount, (_, i) => + 0.25f * MathF.Sin(2f * MathF.PI * 440f * i / SampleRate)); + using var limiterB = CreateTransparentLimiter(lookaheadMs); + limiterB.AddInput(new BufferReplayNode(inputB)); + + using var mixer = new MixerNode(); + mixer.AddInput(limiterA); + mixer.AddInput(limiterB); + mixer.SetBranchEndTime(limiterA, earlyEnd); + mixer.SetBranchEndTime(limiterB, groupEnd); + + using var processed = mixer.Process(Context(TimeSpan.Zero, sampleCount)); + mixer.RemoveInput(limiterA); + using var tail = mixer.Flush(Context(groupEnd, sampleCount)); + + Assert.That(tail.GetChannelData(0)[..L].ToArray(), Has.Some.Not.EqualTo(0f), + "Removing branch A must move branch B's end time with it so B remains live during flush."); + } + + [Test] + public void MixerNode_ClearInputs_DropsStaleBranchEndTimes() + { + const float lookaheadMs = 5f; + const int sampleCount = 1024; + int L = LookaheadSamples(lookaheadMs); + var groupEnd = TimeSpan.FromSeconds((double)sampleCount / SampleRate); + var earlyEnd = TimeSpan.FromSeconds((double)(sampleCount / 2) / SampleRate); + + using var staleInput = CreateConstantBuffer(0f, sampleCount); + using var staleLimiter = CreateTransparentLimiter(lookaheadMs); + staleLimiter.AddInput(new BufferReplayNode(staleInput)); + + using var liveInput = CreateBuffer(2, sampleCount, (_, i) => + 0.25f * MathF.Sin(2f * MathF.PI * 440f * i / SampleRate)); + using var liveLimiter = CreateTransparentLimiter(lookaheadMs); + liveLimiter.AddInput(new BufferReplayNode(liveInput)); + + using var mixer = new MixerNode(); + mixer.AddInput(staleLimiter); + mixer.SetBranchEndTime(staleLimiter, earlyEnd); + mixer.ClearInputs(); + mixer.AddInput(liveLimiter); + + using var processed = mixer.Process(Context(TimeSpan.Zero, sampleCount)); + using var tail = mixer.Flush(Context(groupEnd, sampleCount)); + + Assert.That(tail.GetChannelData(0)[..L].ToArray(), Has.Some.Not.EqualTo(0f), + "A branch added after ClearInputs must not inherit liveness metadata from the old topology."); + } + + [Test] + public void MixerNode_AddInput_WithoutBranchEndTime_RemainsLive() + { + const float lookaheadMs = 5f; + const int sampleCount = 1024; + int L = LookaheadSamples(lookaheadMs); + var groupEnd = TimeSpan.FromSeconds((double)sampleCount / SampleRate); + + using var staleInput = CreateConstantBuffer(0f, sampleCount); + using var staleLimiter = CreateTransparentLimiter(lookaheadMs); + staleLimiter.AddInput(new BufferReplayNode(staleInput)); + + using var liveInput = CreateBuffer(2, sampleCount, (_, i) => + 0.25f * MathF.Sin(2f * MathF.PI * 440f * i / SampleRate)); + using var liveLimiter = CreateTransparentLimiter(lookaheadMs); + liveLimiter.AddInput(new BufferReplayNode(liveInput)); + + using var mixer = new MixerNode(); + mixer.AddInput(staleLimiter); + mixer.SetBranchEndTime(staleLimiter, TimeSpan.Zero); + mixer.AddInput(liveLimiter); + + using var processed = mixer.Process(Context(TimeSpan.Zero, sampleCount)); + using var tail = mixer.Flush(Context(groupEnd, sampleCount)); + + Assert.That(tail.GetChannelData(0)[..L].ToArray(), Has.Some.Not.EqualTo(0f), + "A dynamically added branch without an end time must remain live during flush."); + } + + [Test] + public void MixerNode_ClearBranchEndTime_MakesBranchLiveAgain() + { + const float lookaheadMs = 5f; + const int sampleCount = 1024; + int L = LookaheadSamples(lookaheadMs); + var groupEnd = TimeSpan.FromSeconds((double)sampleCount / SampleRate); + + using var input = CreateBuffer(2, sampleCount, (_, i) => + 0.25f * MathF.Sin(2f * MathF.PI * 440f * i / SampleRate)); + using var limiter = CreateTransparentLimiter(lookaheadMs); + limiter.AddInput(new BufferReplayNode(input)); + + using var mixer = new MixerNode(); + mixer.AddInput(limiter); + mixer.SetBranchEndTime(limiter, TimeSpan.Zero); + + Assert.That(mixer.ClearBranchEndTime(limiter), Is.True); + + using var processed = mixer.Process(Context(TimeSpan.Zero, sampleCount)); + using var tail = mixer.Flush(Context(groupEnd, sampleCount)); + + Assert.That(tail.GetChannelData(0)[..L].ToArray(), Has.Some.Not.EqualTo(0f), + "Clearing a branch end time must return the connected branch to the live state."); + } + + [Test] + public void MixerNode_ClearInputs_WhenEmpty_DropsStaleGains() + { + const int sampleCount = 64; + + using var input = CreateConstantBuffer(0.25f, sampleCount); + using var inputNode = new BufferReplayNode(input); + using var mixer = new MixerNode { Gains = [0f] }; + + mixer.ClearInputs(); + mixer.AddInput(inputNode); + + using var processed = mixer.Process(Context(TimeSpan.Zero, sampleCount)); + + Assert.That(processed.GetChannelData(0)[0], Is.EqualTo(0.25f).Within(1e-6f), + "ClearInputs must clear connection metadata even when the mixer is already empty."); + } + + [Test] + public void MixerNode_SetBranchEndTime_RejectsDisconnectedInput() + { + using var buffer = CreateConstantBuffer(0f, 64); + using var input = new BufferReplayNode(buffer); + using var mixer = new MixerNode(); + + Assert.That( + () => mixer.SetBranchEndTime(input, TimeSpan.Zero), + Throws.ArgumentException.With.Property(nameof(ArgumentException.ParamName)).EqualTo("input")); + } + + [Test] + public void AudioContext_Topology_UsesReferenceIdentityForValueEqualNodes() + { + using var first = new ValueEqualAudioNode(); + using var second = new ValueEqualAudioNode(); + using var mixer = new MixerNode(); + using var context = new AudioContext(SampleRate, 2); + + context.AddNode(first); + context.AddNode(second); + context.Connect(first, mixer); + context.Connect(second, mixer); + + Assert.That(context.Nodes, Has.Count.EqualTo(3)); + Assert.That(mixer.Inputs, Has.Count.EqualTo(2)); + Assert.That(mixer.Inputs[0], Is.SameAs(first)); + Assert.That(mixer.Inputs[1], Is.SameAs(second)); + + context.RemoveNode(second); + + Assert.That(context.Nodes, Has.Count.EqualTo(2)); + Assert.That(mixer.Inputs, Has.Count.EqualTo(1)); + Assert.That(mixer.Inputs[0], Is.SameAs(first)); + } + + [Test] + public void MixerNode_Dispose_ReleasesBranchEndMetadata() + { + (MixerNode mixer, WeakReference inputReference) = CreateDisposedMixerWithBranchEndMetadata(); + + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + Assert.That(inputReference.IsAlive, Is.False); + GC.KeepAlive(mixer); + } + + [Test] + public void MixerNode_AllDeadFlush_PreservesProcessedChannelLayout() + { + const float lookaheadMs = 5f; + const int sampleCount = 1024; + int L = LookaheadSamples(lookaheadMs); + var groupEnd = TimeSpan.FromSeconds((double)sampleCount / SampleRate); + + using var input = CreateBuffer(1, sampleCount, (_, i) => + 0.25f * MathF.Sin(2f * MathF.PI * 440f * i / SampleRate)); + using var inputNode = new BufferReplayNode(input); + using var mixer = new MixerNode(); + mixer.AddInput(inputNode); + mixer.SetBranchEndTime(inputNode, TimeSpan.Zero); + + using var limiter = CreateTransparentLimiter(lookaheadMs); + limiter.AddInput(mixer); + + using var processed = limiter.Process(Context(TimeSpan.Zero, sampleCount)); + using var tail = limiter.Flush(Context(groupEnd, sampleCount)); + + Assert.That(processed.ChannelCount, Is.EqualTo(1)); + Assert.That(tail.ChannelCount, Is.EqualTo(1), + "An all-dead mixer flush must keep the mono layout seen by the downstream limiter."); + Assert.That(tail.GetChannelData(0)[..L].ToArray(), Has.Some.Not.EqualTo(0f), + "Preserving the layout must keep the downstream limiter from resetting and dropping its tail."); + } + + [Test] + public void NestedClipNode_Flush_RemapsToClipLocalTime_RecoversTail() + { + // A SoundGroup mixes child clips and then flushes them through the group's own clip in the + // GROUP's time domain. A nested ClipNode must remap that drain to its clip-local frame (as + // Process does) or the child's cached limiter sees a discontinuity, Reset()s, and drops the tail. + const float lookaheadMs = 5f; + int L = LookaheadSamples(lookaheadMs); + const int clipSamples = 4096; + var clipDuration = TimeSpan.FromSeconds((double)clipSamples / SampleRate); + + var source = new RangeSineNode(SampleRate); + using var limiter = CreateTransparentLimiter(lookaheadMs); + limiter.AddInput(source); + + using var clip = new ClipNode { Start = TimeSpan.Zero, Duration = clipDuration }; + clip.AddInput(limiter); + + // A window ending exactly at the clip end is terminal but leaves no trailing room, so the + // clip's own AppendFlushedTail cannot run (capacity == 0) and the limiter keeps holding its tail. + using var processed = clip.Process(Context(TimeSpan.Zero, clipSamples)); + + // The parent flushes in a time domain deliberately unrelated to the child's clip-local time. + // The base Flush would forward this start to the limiter, trip the discontinuity guard, and + // emit post-reset silence; the clip-local remap keeps the limiter contiguous and drains the tail. + using var tail = clip.Flush(Context(TimeSpan.FromSeconds(123.0), L)); + + var tailData = tail.GetChannelData(0); + bool anyNonZero = false; + for (int k = 0; k < L; k++) + { + if (MathF.Abs(tailData[k]) > 1e-6f) { anyNonZero = true; break; } + } + + Assert.That(anyNonZero, Is.True, + "A nested ClipNode flushed in a parent's time domain must remap to clip-local time so the " + + "cached limiter stays contiguous and drains its held tail instead of resetting to silence."); + } + + [Test] + public void NestedClipNode_Flush_DrainsFromLastProcessedLocalTime_WhenParentTrimsTheClip() + { + // A SoundGroup window can stop before a child's own Duration, trimming it. The child's last + // Process then ended at the trim boundary, not its natural end, so the flush must drain from that + // last processed local time — draining from Duration would jump past the cached limiter, reset it, + // and drop the tail held at the trim boundary. + const float lookaheadMs = 5f; + int L = LookaheadSamples(lookaheadMs); + const int clipSamples = 8192; // the child's natural Duration + const int processedSamples = 4096; // the parent trims the child here, before its end + + var clipDuration = TimeSpan.FromSeconds((double)clipSamples / SampleRate); + var source = new RangeSineNode(SampleRate); + using var limiter = CreateTransparentLimiter(lookaheadMs); + limiter.AddInput(source); + + using var clip = new ClipNode { Start = TimeSpan.Zero, Duration = clipDuration }; + clip.AddInput(limiter); + + // Only the first processedSamples are pulled, so the limiter's last processed local end is there + // and its delay line holds the tail from around that trim boundary. + using var processed = clip.Process(Context(TimeSpan.Zero, processedSamples)); + + using var tail = clip.Flush(Context(TimeSpan.FromSeconds(99.0), L)); + + var tailData = tail.GetChannelData(0); + bool anyNonZero = false; + for (int k = 0; k < L; k++) + { + if (MathF.Abs(tailData[k]) > 1e-6f) { anyNonZero = true; break; } + } + + Assert.That(anyNonZero, Is.True, + "A clip trimmed by its parent must flush from its last processed local time so the cached " + + "limiter stays contiguous and drains the tail held at the trim boundary, not at Duration."); + } + + [Test] + public void NestedClipNode_Flush_ContinuesAfterPartialTailAppend() + { + // A terminal window with some trailing pad — but less than the reported latency — drains only + // part of the held tail and advances the upstream chain past Duration. A later parent flush of + // the same child must continue from that advanced point; restarting at Duration would step the + // cached limiter backward, reset it, and drop the rest of the tail. + const float lookaheadMs = 5f; + int L = LookaheadSamples(lookaheadMs); + int pad = L / 2; // trailing room < L => only a partial append + const int clipSamples = 4096; + var clipDuration = TimeSpan.FromSeconds((double)clipSamples / SampleRate); + + var source = new RangeSineNode(SampleRate); + using var limiter = CreateTransparentLimiter(lookaheadMs); + limiter.AddInput(source); + + using var clip = new ClipNode { Start = TimeSpan.Zero, Duration = clipDuration }; + clip.AddInput(limiter); + + // Window covers the clip plus `pad` samples: AppendFlushedTail drains `pad` of the L held samples. + using var processed = clip.Process(Context(TimeSpan.Zero, clipSamples + pad)); + + using var tail = clip.Flush(Context(TimeSpan.FromSeconds(77.0), L)); + + var tailData = tail.GetChannelData(0); + bool anyNonZero = false; + for (int k = 0; k < L - pad; k++) + { + if (MathF.Abs(tailData[k]) > 1e-6f) { anyNonZero = true; break; } + } + + Assert.That(anyNonZero, Is.True, + "After a partial tail append, the parent flush must continue from the advanced drain position " + + "so the remaining held samples are recovered, not dropped by a backward-discontinuity reset."); + } + + [Test] + public void NestedClipNode_PartialTailAppend_At44100Hz_DoesNotSkipASample() + { + const int sampleRate = 44100; + const int clipSamples = 4096; + const int pad = 3087; + const int latency = 4096; + var clipDuration = ExactDuration(clipSamples, sampleRate); + + using var input = new RecordingLatencyNode(latency); + using var clip = new ClipNode { Start = TimeSpan.Zero, Duration = clipDuration }; + clip.AddInput(input); + + using var processed = clip.Process(ExactContext(TimeSpan.Zero, clipSamples + pad, sampleRate)); + + Assert.That(input.LastFlushSampleCount, Is.EqualTo(pad), + "The partial drain must request exactly 3087 samples; a rounded-up 3088th sample would be " + + "discarded while advancing a stateful input and shift the later flush by one."); + } + + [Test] + public void Flush_FanInWithoutOverride_Throws() + { + // A bare multi-input node has no merge semantics; the base Flush must fail loudly, not drop tails. + using var node = new GainNode { Gain = Property.CreateAnimatable(100f) }; + using var a = CreateConstantBuffer(0.1f, 16); + using var b = CreateConstantBuffer(0.1f, 16); + node.AddInput(new BufferReplayNode(a)); + node.AddInput(new BufferReplayNode(b)); + + Assert.Throws(() => node.Flush(Context(TimeSpan.Zero, 16))); + } + + [Test] + public void LimiterNode_AnimatedLookahead_ReportsWorstCaseLatency() + { + // Base 0 ms but automation rising to 20 ms must reserve the full worst-case drain, or the tail + // is dropped. A static 0 ms (no animation) still reports 0. + var animation = new KeyFrameAnimation + { + KeyFrames = + { + new KeyFrame { KeyTime = TimeSpan.Zero, Value = 0f, Easing = new LinearEasing() }, + new KeyFrame { KeyTime = TimeSpan.FromSeconds(1), Value = 20f, Easing = new LinearEasing() }, + }, + }; + var lookahead = Property.CreateAnimatable(0f); + lookahead.Animation = animation; + + using var node = new LimiterNode + { + Threshold = Property.CreateAnimatable(LimiterParameters.MaxThresholdDb), + Release = Property.CreateAnimatable(LimiterParameters.DefaultReleaseMs), + Lookahead = lookahead, + MakeupGain = Property.CreateAnimatable(0f), + }; + + Assert.That(node.GetLatencySamples(SampleRate), + Is.EqualTo(LookaheadSamples(LimiterParameters.MaxLookaheadMs)), + "Animated lookahead must report the worst case so the drain reserves enough room."); + + // The effect-level API a host queries before graph construction must agree with the node. + var effect = new LimiterEffect(); + effect.Lookahead.Animation = animation; + Assert.That(effect.GetLatencySamples(SampleRate), + Is.EqualTo(LookaheadSamples(LimiterParameters.MaxLookaheadMs))); + } + + [Test] + public void Flush_AnimatedLookaheadDroppingToZero_StillRecoversHeldTail() + { + const float lookaheadMs = 5f; + const int sampleCount = 4096; + int L = LookaheadSamples(lookaheadMs); + var clipDuration = TimeSpan.FromSeconds((double)sampleCount / SampleRate); + var oneSample = TimeSpan.FromSeconds(1.0 / SampleRate); + + using var input = CreateBuffer(2, sampleCount, (_, i) => 0.25f * MathF.Sin(2f * MathF.PI * 220f * i / SampleRate)); + + // Lookahead holds 5 ms across the whole clip, then keyframes to 0 one sample past the clip end. + // The last L inputs were buffered at the 5 ms delay; a drain that re-samples the now-zero + // automation reads delay offset 0 (the flush silence) and drops them. Draining at the lookahead + // retained from the clip's terminal sample must still recover them. + var animation = new KeyFrameAnimation + { + KeyFrames = + { + new KeyFrame { KeyTime = TimeSpan.Zero, Value = lookaheadMs, Easing = new LinearEasing() }, + new KeyFrame { KeyTime = clipDuration, Value = lookaheadMs, Easing = new LinearEasing() }, + new KeyFrame { KeyTime = clipDuration + oneSample, Value = 0f, Easing = new LinearEasing() }, + }, + }; + var lookahead = Property.CreateAnimatable(lookaheadMs); + lookahead.Animation = animation; + + using var node = new LimiterNode + { + Threshold = Property.CreateAnimatable(LimiterParameters.MaxThresholdDb), + Release = Property.CreateAnimatable(LimiterParameters.DefaultReleaseMs), + Lookahead = lookahead, + MakeupGain = Property.CreateAnimatable(0f), + }; + node.AddInput(new BufferReplayNode(input)); + + using var processed = node.Process(Context(TimeSpan.Zero, sampleCount)); + using var tail = node.Flush(Context(clipDuration, sampleCount)); + + var inData = input.GetChannelData(0); + var tailData = tail.GetChannelData(0); + for (int k = 0; k < L; k++) + { + Assert.That(tailData[k], Is.EqualTo(inData[sampleCount - L + k]).Within(1e-5f), + $"Animated-lookahead drain must recover held tail sample {k} at the retained lookahead, not the decayed value."); + } + } + + [Test] + public void Flush_AnimatedMakeupGain_StaysLiveWithStaticLookahead() + { + const float lookaheadMs = 5f; + const float makeupDb = 6f; + const int sampleCount = 4096; + int L = LookaheadSamples(lookaheadMs); + var clipDuration = TimeSpan.FromSeconds((double)sampleCount / SampleRate); + var oneSample = TimeSpan.FromSeconds(1.0 / SampleRate); + + using var input = CreateConstantBuffer(0.1f, sampleCount); + + var animation = new KeyFrameAnimation + { + KeyFrames = + { + new KeyFrame { KeyTime = TimeSpan.Zero, Value = 0f, Easing = new LinearEasing() }, + new KeyFrame { KeyTime = clipDuration - oneSample, Value = 0f, Easing = new LinearEasing() }, + new KeyFrame { KeyTime = clipDuration, Value = makeupDb, Easing = new LinearEasing() }, + }, + }; + var makeup = Property.CreateAnimatable(0f); + makeup.Animation = animation; + + using var node = new LimiterNode + { + Threshold = Property.CreateAnimatable(LimiterParameters.MaxThresholdDb), + Release = Property.CreateAnimatable(LimiterParameters.DefaultReleaseMs), + Lookahead = Property.CreateAnimatable(lookaheadMs), + MakeupGain = makeup, + }; + node.AddInput(new BufferReplayNode(input)); + + using var processed = node.Process(Context(TimeSpan.Zero, sampleCount)); + using var tail = node.Flush(Context(clipDuration, sampleCount)); + + float expected = 0.1f * AudioMath.ConvertDbToLinear(makeupDb); + var tailData = tail.GetChannelData(0); + for (int k = 0; k < L; k++) + { + Assert.That(tailData[k], Is.EqualTo(expected).Within(1e-5f), + $"Flush sample {k} must use the drain-range makeup automation while retaining the terminal lookahead."); + } + } + + [Test] + public void Composer_FlushesSoundEndingExactlyAtTheWindowBoundary() + { + const float lookaheadMs = 5f; + int L = LookaheadSamples(lookaheadMs); + var oneSecond = TimeSpan.FromSeconds(1); + + // The sound spans exactly [0, 1s) and carries a 5 ms lookahead limiter. Window 1 covers it + // exactly, so the clip's terminal window is full (capacity 0) and the held tail cannot + // self-recover. Window 2 excludes the sound (it ended), so only a Composer that flushes the + // just-ended sound recovers the tail into the start of window 2. + var sound = new LimiterTailSound + { + LookaheadMs = lookaheadMs, + TimeRange = new TimeRange(TimeSpan.Zero, oneSecond), + }; + var resource = sound.ToResource(CompositionContext.Default); + + using var composer = new Composer { SampleRate = SampleRate }; + + var window1 = new TimeRange(TimeSpan.Zero, oneSecond); + var eligibility = new CompositionEligibility([sound]); + var frame1 = new CompositionFrame( + ImmutableArray.Create(resource), + window1, + default, + eligibility); + using var buffer1 = composer.Compose(window1, frame1); + + var window2 = new TimeRange(oneSecond, oneSecond); + var frame2 = new CompositionFrame( + ImmutableArray.Empty, + window2, + default, + eligibility); + using var buffer2 = composer.Compose(window2, frame2); + + Assert.That(buffer2, Is.Not.Null); + var tail = buffer2!.GetChannelData(0); + bool tailNonZero = false; + for (int k = 0; k < L; k++) + { + if (MathF.Abs(tail[k]) > 1e-5f) { tailNonZero = true; break; } + } + + Assert.That(tailNonZero, Is.True, + "A sound ending exactly at the window boundary must have its limiter tail flushed into the next window's start."); + } + + [Test] + public void Composer_DoesNotFlushSoundThatBecomesIneligibleAtItsNaturalEnd() + { + const float lookaheadMs = 5f; + int L = LookaheadSamples(lookaheadMs); + var oneSecond = TimeSpan.FromSeconds(1); + + var sound = new LimiterTailSound + { + LookaheadMs = lookaheadMs, + TimeRange = new TimeRange(TimeSpan.Zero, oneSecond), + }; + var resource = sound.ToResource(CompositionContext.Default); + + using var composer = new Composer { SampleRate = SampleRate }; + + var window1 = new TimeRange(TimeSpan.Zero, oneSecond); + var frame1 = new CompositionFrame( + ImmutableArray.Create(resource), + window1, + default, + new CompositionEligibility([sound])); + using var buffer1 = composer.Compose(window1, frame1); + + // The time boundary matches the captured natural end, but the sound is no longer eligible in + // the scene (removed, disabled, muted, or excluded by solo policy), so its cached tail must stop. + var window2 = new TimeRange(oneSecond, oneSecond); + var frame2 = new CompositionFrame( + ImmutableArray.Empty, + window2, + default, + CompositionEligibility.Empty); + using var buffer2 = composer.Compose(window2, frame2); + + Assert.That(buffer2, Is.Not.Null); + var output = buffer2!.GetChannelData(0); + for (int k = 0; k < L; k++) + { + Assert.That(MathF.Abs(output[k]), Is.LessThanOrEqualTo(1e-5f), + $"An ineligible sound must not leak a cached tail at its natural end (sample {k})."); + } + } + + [Test] + public void Composer_DoesNotFlushCachedTailAfterSoundMoves() + { + const float lookaheadMs = 5f; + int L = LookaheadSamples(lookaheadMs); + var oneSecond = TimeSpan.FromSeconds(1); + + var sound = new LimiterTailSound + { + LookaheadMs = lookaheadMs, + TimeRange = new TimeRange(TimeSpan.Zero, oneSecond), + }; + var resource = sound.ToResource(CompositionContext.Default); + + using var composer = new Composer { SampleRate = SampleRate }; + + var window1 = new TimeRange(TimeSpan.Zero, oneSecond); + var eligibility = new CompositionEligibility([sound]); + var frame1 = new CompositionFrame( + ImmutableArray.Create(resource), + window1, + default, + eligibility); + using var buffer1 = composer.Compose(window1, frame1); + + // Moving the still-eligible sound outside the next window must invalidate the old boundary + // relationship. The cached graph contains audio from [0, 1s), but the sound now belongs at 10s. + sound.TimeRange = new TimeRange(TimeSpan.FromSeconds(10), oneSecond); + + var window2 = new TimeRange(oneSecond, oneSecond); + var frame2 = new CompositionFrame( + ImmutableArray.Empty, + window2, + default, + eligibility); + using var buffer2 = composer.Compose(window2, frame2); + + Assert.That(buffer2, Is.Not.Null); + var output = buffer2!.GetChannelData(0); + for (int k = 0; k < L; k++) + { + Assert.That(MathF.Abs(output[k]), Is.LessThanOrEqualTo(1e-5f), + $"A moved sound must not flush a tail cached at its former range (sample {k})."); + } + } + + [Test] + public void Composer_DoesNotFlushCachedTailAfterSoundIsEdited() + { + const float lookaheadMs = 5f; + int L = LookaheadSamples(lookaheadMs); + var oneSecond = TimeSpan.FromSeconds(1); + + var sound = new LimiterTailSound + { + LookaheadMs = lookaheadMs, + TimeRange = new TimeRange(TimeSpan.Zero, oneSecond), + }; + var resource = sound.ToResource(CompositionContext.Default); + + using var composer = new Composer { SampleRate = SampleRate }; + + var window1 = new TimeRange(TimeSpan.Zero, oneSecond); + var eligibility = new CompositionEligibility([sound]); + var frame1 = new CompositionFrame( + ImmutableArray.Create(resource), + window1, + default, + eligibility); + using var buffer1 = composer.Compose(window1, frame1); + + // Any tracked sound edit invalidates its cached graph. Because the sound ended at the window + // boundary, it is absent from the next frame and cannot be rebuilt before the tail decision. + sound.Gain.CurrentValue = 50f; + + var window2 = new TimeRange(oneSecond, oneSecond); + var frame2 = new CompositionFrame( + ImmutableArray.Empty, + window2, + default, + eligibility); + using var buffer2 = composer.Compose(window2, frame2); + + Assert.That(buffer2, Is.Not.Null); + var output = buffer2!.GetChannelData(0); + for (int k = 0; k < L; k++) + { + Assert.That(MathF.Abs(output[k]), Is.LessThanOrEqualTo(1e-5f), + $"An edited sound must not flush a tail from its dirty cached graph (sample {k})."); + } + } + + [Test] + public void Composer_DoesNotFlushSoundThatDisappearsBeforeItsNaturalEnd() + { + const float lookaheadMs = 5f; + int L = LookaheadSamples(lookaheadMs); + var oneSecond = TimeSpan.FromSeconds(1); + var twoSeconds = TimeSpan.FromSeconds(2); + + var sound = new LimiterTailSound + { + LookaheadMs = lookaheadMs, + TimeRange = new TimeRange(TimeSpan.Zero, twoSeconds), + }; + var resource = sound.ToResource(CompositionContext.Default); + + using var composer = new Composer { SampleRate = SampleRate }; + + var window1 = new TimeRange(TimeSpan.Zero, oneSecond); + var frame1 = new CompositionFrame( + ImmutableArray.Create(resource), + window1, + default, + new CompositionEligibility([sound])); + using var buffer1 = composer.Compose(window1, frame1); + + // The sound is absent from the next contiguous frame even though its captured range continues. + // This models a mute, disable, or removal rather than a natural clip end. + var window2 = new TimeRange(oneSecond, oneSecond); + var frame2 = new CompositionFrame( + ImmutableArray.Empty, + window2, + default, + CompositionEligibility.Empty); + using var buffer2 = composer.Compose(window2, frame2); + + Assert.That(buffer2, Is.Not.Null); + var output = buffer2!.GetChannelData(0); + for (int k = 0; k < L; k++) + { + Assert.That(MathF.Abs(output[k]), Is.LessThanOrEqualTo(1e-5f), + $"A sound removed before its natural end must not leak a cached limiter tail (sample {k})."); + } + } + + // Guard path 1: a non-contiguous window (seek/scrub/restart) must suppress the ended-sound flush. + // Same setup as the boundary test, but window 2 jumps forward so it no longer abuts window 1; the + // cached limiter reset on the discontinuity anyway, so flushing it would inject a stale tail at the + // wrong time. This pins the IsContiguous early-return in AppendEndedSoundTails. + [Test] + public void Composer_DoesNotFlushEndedSoundTail_AfterNonContiguousSeek() + { + const float lookaheadMs = 5f; + int L = LookaheadSamples(lookaheadMs); + var oneSecond = TimeSpan.FromSeconds(1); + + var sound = new LimiterTailSound + { + LookaheadMs = lookaheadMs, + TimeRange = new TimeRange(TimeSpan.Zero, oneSecond), + }; + var resource = sound.ToResource(CompositionContext.Default); + + using var composer = new Composer { SampleRate = SampleRate }; + + var window1 = new TimeRange(TimeSpan.Zero, oneSecond); + var eligibility = new CompositionEligibility([sound]); + var frame1 = new CompositionFrame( + ImmutableArray.Create(resource), + window1, + default, + eligibility); + using var buffer1 = composer.Compose(window1, frame1); + + // A forward jump far past the previous window end — not contiguous. + var window2 = new TimeRange(TimeSpan.FromSeconds(3), oneSecond); + var frame2 = new CompositionFrame( + ImmutableArray.Empty, + window2, + default, + eligibility); + using var buffer2 = composer.Compose(window2, frame2); + + Assert.That(buffer2, Is.Not.Null); + var tail = buffer2!.GetChannelData(0); + for (int k = 0; k < L; k++) + { + Assert.That(MathF.Abs(tail[k]), Is.LessThanOrEqualTo(1e-5f), + $"A discontinuous window must not inject the previous clip's stale limiter tail (sample {k})."); + } + } + + // Guard path 2: InvalidateCache must clear the recorded previous window so a subsequent contiguous + // window does not flush a stale tail. Identical to the boundary test (contiguous window 2 that + // normally recovers the tail) except for the InvalidateCache call, which must suppress the flush. + [Test] + public void Composer_InvalidateCache_SuppressesEndedSoundTailFlush() + { + const float lookaheadMs = 5f; + int L = LookaheadSamples(lookaheadMs); + var oneSecond = TimeSpan.FromSeconds(1); + + var sound = new LimiterTailSound + { + LookaheadMs = lookaheadMs, + TimeRange = new TimeRange(TimeSpan.Zero, oneSecond), + }; + var resource = sound.ToResource(CompositionContext.Default); + + using var composer = new Composer { SampleRate = SampleRate }; + + var window1 = new TimeRange(TimeSpan.Zero, oneSecond); + var eligibility = new CompositionEligibility([sound]); + var frame1 = new CompositionFrame( + ImmutableArray.Create(resource), + window1, + default, + eligibility); + using var buffer1 = composer.Compose(window1, frame1); + + composer.InvalidateCache(); + + // Contiguous with window 1 — without InvalidateCache this window recovers the tail (boundary test). + var window2 = new TimeRange(oneSecond, oneSecond); + var frame2 = new CompositionFrame( + ImmutableArray.Empty, + window2, + default, + eligibility); + using var buffer2 = composer.Compose(window2, frame2); + + Assert.That(buffer2, Is.Not.Null); + var tail = buffer2!.GetChannelData(0); + for (int k = 0; k < L; k++) + { + Assert.That(MathF.Abs(tail[k]), Is.LessThanOrEqualTo(1e-5f), + $"InvalidateCache must drop the recorded previous window so no stale tail is flushed (sample {k})."); + } + } + + // The three nodes refactored to delegate Process to ProcessTail must still drain correctly on the + // base Flush path (draining: true). Each guards buffer ownership and shape, not a specific tail value. + [Test] + public void DelayNode_Flush_DrainsThroughProcessTail_NoThrow() + { + const int sampleCount = 512; + using var input = CreateBuffer(2, sampleCount, (_, i) => 0.25f * MathF.Sin(2f * MathF.PI * 220f * i / SampleRate)); + using var node = new DelayNode + { + DelayTime = Property.Create(5f), + Feedback = Property.Create(50f), + DryMix = Property.Create(50f), + WetMix = Property.Create(50f), + }; + node.AddInput(new BufferReplayNode(input)); + + using var processed = node.Process(Context(TimeSpan.Zero, sampleCount)); + using var tail = node.Flush(Context(TimeSpan.FromSeconds((double)sampleCount / SampleRate), sampleCount)); + + Assert.That(tail.ChannelCount, Is.EqualTo(processed.ChannelCount)); + Assert.That(tail.SampleCount, Is.EqualTo(sampleCount)); + } + + [Test] + public void CompressorNode_Flush_DrainsThroughProcessTail_NoThrow() + { + const int sampleCount = 512; + using var input = CreateBuffer(2, sampleCount, (_, i) => 0.25f * MathF.Sin(2f * MathF.PI * 220f * i / SampleRate)); + using var node = new CompressorNode + { + Threshold = Property.Create(-20f), + Ratio = Property.Create(4f), + Attack = Property.Create(5f), + Release = Property.Create(50f), + Knee = Property.Create(0f), + MakeupGain = Property.Create(0f), + }; + node.AddInput(new BufferReplayNode(input)); + + using var processed = node.Process(Context(TimeSpan.Zero, sampleCount)); + using var tail = node.Flush(Context(TimeSpan.FromSeconds((double)sampleCount / SampleRate), sampleCount)); + + Assert.That(tail.ChannelCount, Is.EqualTo(processed.ChannelCount)); + Assert.That(tail.SampleCount, Is.EqualTo(sampleCount)); + } + + [Test] + public void EqualizerNode_Flush_DrainsThroughProcessTail_NoThrow() + { + // A band present takes the fresh-buffer (non-pass-through) ownership path. + const int sampleCount = 512; + using var input = CreateBuffer(2, sampleCount, (_, i) => 0.25f * MathF.Sin(2f * MathF.PI * 220f * i / SampleRate)); + using var node = new EqualizerNode { Bands = [new EqualizerBand()] }; + node.AddInput(new BufferReplayNode(input)); + + using var processed = node.Process(Context(TimeSpan.Zero, sampleCount)); + using var tail = node.Flush(Context(TimeSpan.FromSeconds((double)sampleCount / SampleRate), sampleCount)); + + Assert.That(tail.ChannelCount, Is.EqualTo(processed.ChannelCount)); + Assert.That(tail.SampleCount, Is.EqualTo(sampleCount)); + } + + // Source that honors the requested clip-local range: sample value keyed to the absolute clip-local + // index so a downstream node's output is identifiable, and reads past the clip end return silence + // (mirrors SourceNode returning silence for out-of-clip reads, the precondition Flush relies on). + private sealed class RangeSineNode(int sampleRate) : AudioNode + { + public override AudioBuffer Process(AudioProcessContext context) + { + int count = context.GetSampleCount(); + var buffer = new AudioBuffer(sampleRate, 2, count); + long startIndex = AudioMath.TimeToSampleIndex(context.TimeRange.Start, sampleRate); + for (int ch = 0; ch < 2; ch++) + { + var data = buffer.GetChannelData(ch); + for (int i = 0; i < count; i++) + { + data[i] = 0.25f * MathF.Sin(2f * MathF.PI * 200f * (startIndex + i) / sampleRate); + } + } + + return buffer; + } + } + + private static AudioProcessContext ExactContext(TimeSpan start, int sampleCount, int sampleRate) + => new( + new TimeRange(start, ExactDuration(sampleCount, sampleRate)), + sampleRate, + new AnimationSampler(), + null); + + private static TimeSpan ExactDuration(int sampleCount, int sampleRate) + => AudioProcessContext.GetDurationForSampleCount(sampleCount, sampleRate); + + private static bool HasNonZero(ReadOnlySpan samples) + { + foreach (float sample in samples) + { + if (MathF.Abs(sample) > 1e-6f) + return true; + } + + return false; + } + + private static KeyFrameAnimation ConstantSpeedAnimation(float speed) + => new() + { + KeyFrames = + { + new KeyFrame + { + KeyTime = TimeSpan.Zero, + Value = speed, + Easing = new LinearEasing(), + }, + new KeyFrame + { + KeyTime = TimeSpan.FromSeconds(10), + Value = speed, + Easing = new LinearEasing(), + }, + }, + }; + + private sealed class RecordingFlushRequestNode : AudioNode + { + public int TotalFlushedSamples { get; private set; } + + public TimeSpan? LastProcessedEnd { get; private set; } + + public TimeSpan? FirstFlushStart { get; private set; } + + public override AudioBuffer Process(AudioProcessContext context) + { + LastProcessedEnd = context.TimeRange.End; + return new AudioBuffer(context.SampleRate, 2, context.GetSampleCount()); + } + + public override AudioBuffer Flush(AudioProcessContext context) + { + FirstFlushStart ??= context.TimeRange.Start; + int sampleCount = context.GetSampleCount(); + TotalFlushedSamples += sampleCount; + var buffer = new AudioBuffer(context.SampleRate, 2, sampleCount); + buffer.GetChannelData(0).Fill(0.25f); + buffer.GetChannelData(1).Fill(0.25f); + return buffer; + } + } + + private sealed class RecordingLatencyNode(int latencySamples) : AudioNode + { + public int LastFlushSampleCount { get; private set; } = -1; + + public override AudioBuffer Process(AudioProcessContext context) + => new(context.SampleRate, 2, context.GetSampleCount()); + + public override AudioBuffer Flush(AudioProcessContext context) + { + LastFlushSampleCount = context.GetSampleCount(); + return new AudioBuffer(context.SampleRate, 2, LastFlushSampleCount); + } + + public override int GetLatencySamples(int sampleRate) => latencySamples; + } + + [System.Runtime.CompilerServices.MethodImpl( + System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + private static (MixerNode Mixer, WeakReference InputReference) CreateDisposedMixerWithBranchEndMetadata() + { + var input = new ValueEqualAudioNode(); + var mixer = new MixerNode(); + mixer.AddInput(input); + mixer.SetBranchEndTime(input, TimeSpan.Zero); + mixer.Dispose(); + return (mixer, new WeakReference(input)); + } + + private sealed class ValueEqualAudioNode : AudioNode + { + public override AudioBuffer Process(AudioProcessContext context) + => new(context.SampleRate, 2, context.GetSampleCount()); + + public override bool Equals(object? obj) => obj is ValueEqualAudioNode; + + public override int GetHashCode() => 0; + } +} diff --git a/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyTests.cs b/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyTests.cs new file mode 100644 index 0000000000..d9023feb3e --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyTests.cs @@ -0,0 +1,470 @@ +using Beutl.Animation; +using Beutl.Animation.Easings; +using Beutl.Audio; +using Beutl.Audio.Effects; +using Beutl.Audio.Graph; +using Beutl.Audio.Graph.Nodes; +using Beutl.Engine; +using Beutl.Logging; +using Beutl.Media; +using Microsoft.Extensions.Logging; +using Moq; + +using static Beutl.UnitTests.Engine.Audio.AudioTestBuffers; + +namespace Beutl.UnitTests.Engine.Audio; + +[TestFixture] +public class AudioLatencyTests +{ + private const int SampleRate = 48000; + + [OneTimeSetUp] + public void OneTimeSetUp() + { + // LimiterNode touches Log on construction/processing; Log.LoggerFactory is write-once (??=). + if (Log.LoggerFactory is null) + { + Log.LoggerFactory = LoggerFactory.Create(_ => { }); + } + } + + // Reference math the limiter uses to convert a lookahead time to samples (unclamped; every value + // exercised here is within the 0..20 ms range, where it matches LimiterNode's clamped result). + private static int ExpectedLookaheadSamples(float lookaheadMs, int sampleRate) + => (int)(lookaheadMs / 1000f * sampleRate); + + private static LimiterNode CreateLimiterNode(float lookaheadMs) + => new() + { + Threshold = Property.CreateAnimatable(LimiterParameters.DefaultThresholdDb), + Release = Property.CreateAnimatable(LimiterParameters.DefaultReleaseMs), + Lookahead = Property.CreateAnimatable(lookaheadMs), + MakeupGain = Property.CreateAnimatable(LimiterParameters.DefaultMakeupGainDb), + }; + + private static LimiterEffect CreateLimiterEffect(float lookaheadMs) + { + var effect = new LimiterEffect(); + effect.Lookahead.CurrentValue = lookaheadMs; + return effect; + } + + private static AudioProcessContext CreateContext(int sampleCount, int sampleRate = SampleRate) + { + var duration = TimeSpan.FromSeconds((double)sampleCount / sampleRate); + return new AudioProcessContext(new TimeRange(TimeSpan.Zero, duration), sampleRate, new AnimationSampler(), null); + } + + // Baseline characterization: drives the real Process path and measures the delay an impulse incurs, + // pinning the ground-truth latency the reporting API must reproduce. Runs green against unmodified + // code, mirroring LimiterNodeTests.Process_LookaheadDelay_IsAccurate. + [TestCase(5f)] + [TestCase(10f)] + public void Process_DelaysImpulse_ByLookaheadSamples(float lookaheadMs) + { + const int sampleCount = 4096; + int expected = ExpectedLookaheadSamples(lookaheadMs, SampleRate); + + // An isolated impulse stays below the limiter threshold, so it passes through delayed but + // un-attenuated; its peak position is the applied delay. + using var input = CreateBuffer(2, sampleCount, (_, i) => i == 0 ? 0.5f : 0f); + + using var node = CreateLimiterNode(lookaheadMs); + node.AddInput(new BufferReplayNode(input)); + + using var output = node.Process(CreateContext(sampleCount)); + + var data = output.GetChannelData(0); + int peakIndex = 0; + float peak = 0f; + for (int i = 0; i < sampleCount; i++) + { + float abs = MathF.Abs(data[i]); + if (abs > peak) + { + peak = abs; + peakIndex = i; + } + } + + Assert.That(peakIndex, Is.EqualTo(expected), + "The impulse should emerge delayed by exactly the lookahead samples."); + } + + [TestCase(0f, true)] + [TestCase(5f, false)] + [TestCase(20f, false)] + public void LimiterNode_GetLatencySamples_MatchesLookahead_At48k(float lookaheadMs, bool expectedZero) + { + using var node = CreateLimiterNode(lookaheadMs); + + int latency = node.GetLatencySamples(SampleRate); + + Assert.That(latency, Is.EqualTo(ExpectedLookaheadSamples(lookaheadMs, SampleRate))); + Assert.That(latency == 0, Is.EqualTo(expectedZero)); + } + + [TestCase(5f)] + [TestCase(20f)] + public void LimiterNode_GetLatencySamples_ScalesWithSampleRate(float lookaheadMs) + { + using var node = CreateLimiterNode(lookaheadMs); + + int at48k = node.GetLatencySamples(48000); + int at96k = node.GetLatencySamples(96000); + + Assert.That(at48k, Is.EqualTo(ExpectedLookaheadSamples(lookaheadMs, 48000))); + Assert.That(at96k, Is.EqualTo(ExpectedLookaheadSamples(lookaheadMs, 96000))); + Assert.That(at96k, Is.EqualTo(at48k * 2), "Doubling the sample rate doubles the sample latency."); + } + + [Test] + public void LimiterNode_GetLatencySamples_QueryableBeforeProcess() + { + // A freshly constructed node has never initialized its delay-line buffers; the report must not + // depend on _maxLookaheadSamples being set by a prior Process call. + using var node = CreateLimiterNode(5f); + + Assert.That(node.GetLatencySamples(SampleRate), Is.EqualTo(ExpectedLookaheadSamples(5f, SampleRate))); + } + + // 20 ms is the MaxLookaheadMs boundary where ToLatencySamples and LimiterNode.Derive both clamp; + // driving Process there confirms the report still equals the delay actually applied. + [TestCase(5f)] + [TestCase(20f)] + public void LimiterNode_GetLatencySamples_MatchesActualDelay(float lookaheadMs) + { + const int sampleCount = 4096; + using var input = CreateBuffer(2, sampleCount, (_, i) => i == 0 ? 0.5f : 0f); + + using var node = CreateLimiterNode(lookaheadMs); + node.AddInput(new BufferReplayNode(input)); + + int reported = node.GetLatencySamples(SampleRate); + + using var output = node.Process(CreateContext(sampleCount)); + var data = output.GetChannelData(0); + int peakIndex = 0; + float peak = 0f; + for (int i = 0; i < sampleCount; i++) + { + float abs = MathF.Abs(data[i]); + if (abs > peak) + { + peak = abs; + peakIndex = i; + } + } + + Assert.That(reported, Is.EqualTo(peakIndex), + "The reported latency must equal the delay Process actually applies."); + } + + [Test] + public void PassThroughNodes_GetLatencySamples_AreZero() + { + using var gain = new GainNode { Gain = Property.CreateAnimatable(100f) }; + Assert.That(gain.GetLatencySamples(SampleRate), Is.EqualTo(0)); + + using var buffer = CreateConstantBuffer(0.1f, 16); + using var replay = new BufferReplayNode(buffer); + Assert.That(replay.GetLatencySamples(SampleRate), Is.EqualTo(0), "AudioNode default is zero latency."); + } + + [Test] + public void Effects_GetLatencySamples_ZeroForNonLatencyEffects() + { + var compressor = new CompressorEffect(); + var equalizer = new EqualizerEffect(); + + Assert.That(compressor.GetLatencySamples(SampleRate), Is.EqualTo(0)); + Assert.That(equalizer.GetLatencySamples(SampleRate), Is.EqualTo(0)); + } + + [Test] + public void LimiterEffect_GetLatencySamples_MatchesNode() + { + var effect = CreateLimiterEffect(5f); + using var node = CreateLimiterNode(5f); + + Assert.That(effect.GetLatencySamples(SampleRate), Is.EqualTo(node.GetLatencySamples(SampleRate))); + Assert.That(effect.GetLatencySamples(SampleRate), Is.EqualTo(ExpectedLookaheadSamples(5f, SampleRate))); + } + + [Test] + public void LimiterEffect_GetLatencySamples_DisabledReportsZero() + { + var effect = CreateLimiterEffect(5f); + effect.IsEnabled = false; + + Assert.That(effect.GetLatencySamples(SampleRate), Is.EqualTo(0)); + } + + [Test] + public void AudioEffectGroup_GetLatencySamples_SumsEnabledChildren() + { + var group = new AudioEffectGroup(); + group.Children.Add(CreateLimiterEffect(5f)); + group.Children.Add(CreateLimiterEffect(10f)); + + int expected = ExpectedLookaheadSamples(5f, SampleRate) + ExpectedLookaheadSamples(10f, SampleRate); + Assert.That(group.GetLatencySamples(SampleRate), Is.EqualTo(expected)); + } + + [Test] + public void AudioEffectGroup_GetLatencySamples_ExcludesDisabledChildren() + { + var disabled = CreateLimiterEffect(10f); + disabled.IsEnabled = false; + + var group = new AudioEffectGroup(); + group.Children.Add(CreateLimiterEffect(5f)); + group.Children.Add(disabled); + + Assert.That(group.GetLatencySamples(SampleRate), Is.EqualTo(ExpectedLookaheadSamples(5f, SampleRate))); + } + + [Test] + public void AudioEffectGroup_GetLatencySamples_DisabledGroupReportsZero() + { + // A disabled group contributes no nodes (Sound.Compose skips its CreateNode), so its pre-graph + // latency report is 0 too — consistent with LimiterEffect's own IsEnabled gate. + var group = new AudioEffectGroup { IsEnabled = false }; + group.Children.Add(CreateLimiterEffect(5f)); + group.Children.Add(CreateLimiterEffect(10f)); + + Assert.That(group.GetLatencySamples(SampleRate), Is.EqualTo(0)); + } + + [TestCase(int.MaxValue, 1)] + [TestCase(int.MaxValue - 10, 20)] + public void AudioEffectGroup_GetLatencySamples_SaturatesUnboundedOrOverflowingTotals( + int firstLatency, + int secondLatency) + { + var group = new AudioEffectGroup(); + group.Children.Add(new FixedLatencyEffect(firstLatency)); + group.Children.Add(new FixedLatencyEffect(secondLatency)); + + Assert.That(group.GetLatencySamples(SampleRate), Is.EqualTo(int.MaxValue)); + } + + [TestCase(false)] + [TestCase(true)] + public void AudioEffectGroup_GetLatencySamples_NegativeChildThrows(bool followedByUnbounded) + { + var group = new AudioEffectGroup(); + group.Children.Add(new FixedLatencyEffect(-1)); + if (followedByUnbounded) + { + group.Children.Add(new FixedLatencyEffect(int.MaxValue)); + } + + InvalidOperationException? exception = Assert.Throws( + () => group.GetLatencySamples(SampleRate)); + Assert.That(exception!.Message, Does.Contain(nameof(FixedLatencyEffect)).And.Contain("-1")); + } + + [Test] + public void GetTotalLatencySamples_OverLinearCascade_Sums() + { + using var buffer = CreateConstantBuffer(0.1f, 16); + using var source = new BufferReplayNode(buffer); + using var first = CreateLimiterNode(5f); + using var second = CreateLimiterNode(10f); + first.AddInput(source); + second.AddInput(first); + + int expected = ExpectedLookaheadSamples(5f, SampleRate) + ExpectedLookaheadSamples(10f, SampleRate); + Assert.That(second.GetTotalLatencySamples(SampleRate), Is.EqualTo(expected)); + Assert.That(first.GetTotalLatencySamples(SampleRate), Is.EqualTo(ExpectedLookaheadSamples(5f, SampleRate)), + "The leaf BufferReplayNode feeding the cascade contributes zero latency."); + } + + [Test] + public void GetTotalLatencySamples_OverFanIn_TakesMax() + { + // A mixer aligns its branches to the slowest, so the total is the max branch latency, not the + // sum — guards against double-counting sibling inputs. + using var bufferA = CreateConstantBuffer(0.1f, 16); + using var bufferB = CreateConstantBuffer(0.1f, 16); + using var branchA = CreateLimiterNode(5f); + using var branchB = CreateLimiterNode(10f); + branchA.AddInput(new BufferReplayNode(bufferA)); + branchB.AddInput(new BufferReplayNode(bufferB)); + + using var mixer = new MixerNode(); + mixer.AddInput(branchA); + mixer.AddInput(branchB); + + int slowest = ExpectedLookaheadSamples(10f, SampleRate); + Assert.That(mixer.GetTotalLatencySamples(SampleRate), Is.EqualTo(slowest)); + } + + [TestCase(50f)] + [TestCase(100f)] + [TestCase(200f)] + public void SpeedNode_GetTotalLatencySamples_ConvertsUpstreamLatencyToOutputDomain(float speedPercent) + { + using var limiter = CreateLimiterNode(5f); + using var speed = new SpeedNode { Speed = Property.CreateAnimatable(speedPercent) }; + speed.AddInput(limiter); + + int upstreamLatency = ExpectedLookaheadSamples(5f, SampleRate); + int expected = (int)Math.Ceiling(upstreamLatency / (speedPercent / 100d)); + + Assert.That(speed.GetTotalLatencySamples(SampleRate), Is.EqualTo(expected)); + } + + [Test] + public void SpeedNode_GetTotalLatencySamples_AnimatedSpeedUsesSlowestKeyFrame() + { + var speedProperty = Property.CreateAnimatable(200f); + speedProperty.Animation = new KeyFrameAnimation + { + KeyFrames = + { + new KeyFrame { KeyTime = TimeSpan.Zero, Value = 200f }, + new KeyFrame { KeyTime = TimeSpan.FromSeconds(1), Value = 50f }, + }, + }; + using var limiter = CreateLimiterNode(5f); + using var speed = new SpeedNode { Speed = speedProperty }; + speed.AddInput(limiter); + + int upstreamLatency = ExpectedLookaheadSamples(5f, SampleRate); + + Assert.That(speed.GetTotalLatencySamples(SampleRate), Is.EqualTo(upstreamLatency * 2)); + } + + [Test] + public void SpeedNode_GetTotalLatencySamples_BackEaseUsesItsOvershootBound() + { + var easing = new BackEaseIn(); + var speedProperty = Property.CreateAnimatable(100f); + speedProperty.Animation = new KeyFrameAnimation + { + KeyFrames = + { + new KeyFrame { KeyTime = TimeSpan.Zero, Value = 100f }, + new KeyFrame + { + KeyTime = TimeSpan.FromSeconds(1), + Value = 200f, + Easing = easing, + }, + }, + }; + using var limiter = CreateLimiterNode(5f); + using var speed = new SpeedNode { Speed = speedProperty }; + speed.AddInput(limiter); + + float sampledMinimum = float.PositiveInfinity; + for (int i = 0; i <= 1000; i++) + { + float progress = i / 1000f; + sampledMinimum = Math.Min(sampledMinimum, 100f + easing.Ease(progress) * 100f); + } + + int upstreamLatency = ExpectedLookaheadSamples(5f, SampleRate); + int requiredForObservedOvershoot = (int)Math.Ceiling(upstreamLatency / (sampledMinimum / 100d)); + + Assert.That(speed.GetTotalLatencySamples(SampleRate), Is.GreaterThanOrEqualTo(requiredForObservedOvershoot)); + } + + [Test] + public void SpeedNode_GetTotalLatencySamples_UnknownEasingSaturates() + { + var speedProperty = Property.CreateAnimatable(100f); + speedProperty.Animation = new KeyFrameAnimation + { + KeyFrames = + { + new KeyFrame { KeyTime = TimeSpan.Zero, Value = 100f }, + new KeyFrame + { + KeyTime = TimeSpan.FromSeconds(1), + Value = 200f, + Easing = new UnknownRangeEasing(), + }, + }, + }; + using var limiter = CreateLimiterNode(5f); + using var speed = new SpeedNode { Speed = speedProperty }; + speed.AddInput(limiter); + + Assert.That(speed.GetTotalLatencySamples(SampleRate), Is.EqualTo(int.MaxValue)); + } + + [Test] + public void SpeedNode_GetTotalLatencySamples_UnknownAnimationSaturates() + { + var speedProperty = Property.CreateAnimatable(100f); + speedProperty.Animation = new Mock>().Object; + using var limiter = CreateLimiterNode(5f); + using var speed = new SpeedNode { Speed = speedProperty }; + speed.AddInput(limiter); + + Assert.That(speed.GetTotalLatencySamples(SampleRate), Is.EqualTo(int.MaxValue)); + } + + [Test] + public void SpeedNode_GetTotalLatencySamples_StoppedSpeedSaturates() + { + using var limiter = CreateLimiterNode(5f); + using var speed = new SpeedNode { Speed = Property.CreateAnimatable(0f) }; + speed.AddInput(limiter); + + Assert.That(speed.GetTotalLatencySamples(SampleRate), Is.EqualTo(int.MaxValue)); + } + + [Test] + public void GetTotalLatencySamples_DownstreamLatencyPreservesUpstreamSaturation() + { + using var upstreamLimiter = CreateLimiterNode(5f); + using var stopped = new SpeedNode { Speed = Property.CreateAnimatable(0f) }; + using var downstreamLimiter = CreateLimiterNode(5f); + stopped.AddInput(upstreamLimiter); + downstreamLimiter.AddInput(stopped); + + Assert.That(downstreamLimiter.GetTotalLatencySamples(SampleRate), Is.EqualTo(int.MaxValue)); + } + + [TestCase(0)] + [TestCase(-1)] + public void GetLatencySamples_NonPositiveSampleRate_Throws(int sampleRate) + { + using var gain = new GainNode { Gain = Property.CreateAnimatable(100f) }; + using var limiterNode = CreateLimiterNode(5f); + var limiterEffect = CreateLimiterEffect(5f); + var group = new AudioEffectGroup(); + + // Every reporting entry point guards the rate, so the contract is uniform across node types, + // not just the ones that reach LimiterParameters.ToLatencySamples. + Assert.Throws(() => gain.GetLatencySamples(sampleRate)); + Assert.Throws(() => limiterNode.GetLatencySamples(sampleRate)); + Assert.Throws(() => limiterEffect.GetLatencySamples(sampleRate)); + Assert.Throws(() => group.GetLatencySamples(sampleRate)); + + // The aggregating entry point guards independently, so an override that folds the upstream + // recursion before delegating still honors the contract. + Assert.Throws(() => gain.GetTotalLatencySamples(sampleRate)); + Assert.Throws(() => limiterNode.GetTotalLatencySamples(sampleRate)); + using var speed = new SpeedNode { Speed = Property.CreateAnimatable(50f) }; + Assert.Throws(() => speed.GetTotalLatencySamples(sampleRate)); + } + + private sealed class UnknownRangeEasing : Easing + { + public override float Ease(float progress) => progress; + } + +} + +internal sealed partial class FixedLatencyEffect(int latencySamples) : AudioEffect +{ + public override AudioNode CreateNode(AudioContext context, AudioNode inputNode) => inputNode; + + public override int GetLatencySamples(int sampleRate) => latencySamples; +} diff --git a/tests/Beutl.UnitTests/Engine/Audio/AudioNodeTests.cs b/tests/Beutl.UnitTests/Engine/Audio/AudioNodeTests.cs new file mode 100644 index 0000000000..a4647f8bec --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Audio/AudioNodeTests.cs @@ -0,0 +1,152 @@ +using Beutl.Audio; +using Beutl.Audio.Graph; + +namespace Beutl.UnitTests.Engine.Audio; + +[TestFixture] +public class AudioNodeTests +{ + [Test] + public void AddInput_WhenHookThrows_RestoresTopologyAndAllowsRetry() + { + using var node = new ThrowingHookNode(); + using var existing = new ValueNode(); + using var input = new ValueNode(); + node.AddInput(existing); + node.ThrowOnAdd = true; + + Assert.Throws(() => node.AddInput(input)); + Assert.That(node.Inputs, Has.Count.EqualTo(1)); + Assert.That(node.Inputs[0], Is.SameAs(existing)); + Assert.That(node.MetadataInputs, Has.Count.EqualTo(1)); + Assert.That(node.MetadataInputs[0], Is.SameAs(existing)); + + node.ThrowOnAdd = false; + node.AddInput(input); + + Assert.That(node.Inputs, Has.Count.EqualTo(2)); + Assert.That(node.Inputs[0], Is.SameAs(existing)); + Assert.That(node.Inputs[1], Is.SameAs(input)); + Assert.That(node.MetadataInputs, Has.Count.EqualTo(2)); + Assert.That(node.MetadataInputs[0], Is.SameAs(existing)); + Assert.That(node.MetadataInputs[1], Is.SameAs(input)); + } + + [Test] + public void AudioContextConnect_WhenAddHookThrows_AllowsAConsistentRetry() + { + using var context = new AudioContext(48000, 2); + using var source = new ValueNode(); + using var destination = new ThrowingHookNode { ThrowOnAdd = true }; + + Assert.Throws(() => context.Connect(source, destination)); + Assert.That(destination.Inputs, Is.Empty); + Assert.That(destination.MetadataInputs, Is.Empty); + + destination.ThrowOnAdd = false; + context.Connect(source, destination); + + Assert.That(destination.Inputs, Has.Count.EqualTo(1)); + Assert.That(destination.Inputs[0], Is.SameAs(source)); + Assert.That(destination.MetadataInputs[0], Is.SameAs(source)); + + context.RemoveNode(source); + Assert.That(destination.Inputs, Is.Empty, + "The successful retry must be recorded by AudioContext so later topology changes stay aligned."); + } + + [Test] + public void RemoveInput_WhenHookThrows_RestoresTopologyAndAllowsRetry() + { + using var node = new ThrowingHookNode(); + using var input = new ValueNode(); + node.AddInput(input); + node.ThrowOnRemove = true; + + Assert.Throws(() => node.RemoveInput(input)); + Assert.That(node.Inputs, Has.Count.EqualTo(1)); + Assert.That(node.Inputs[0], Is.SameAs(input)); + Assert.That(node.MetadataInputs[0], Is.SameAs(input)); + + node.ThrowOnRemove = false; + node.RemoveInput(input); + + Assert.That(node.Inputs, Is.Empty); + Assert.That(node.MetadataInputs, Is.Empty); + } + + [Test] + public void ClearInputs_WhenHookThrows_RestoresTopologyAndAllowsRetry() + { + using var node = new ThrowingHookNode(); + using var first = new ValueNode(); + using var second = new ValueNode(); + node.AddInput(first); + node.AddInput(second); + node.ThrowOnClear = true; + + Assert.Throws(() => node.ClearInputs()); + Assert.That(node.Inputs, Has.Count.EqualTo(2)); + Assert.That(node.Inputs[0], Is.SameAs(first)); + Assert.That(node.Inputs[1], Is.SameAs(second)); + Assert.That(node.MetadataInputs[0], Is.SameAs(first)); + Assert.That(node.MetadataInputs[1], Is.SameAs(second)); + + node.ThrowOnClear = false; + node.ClearInputs(); + + Assert.That(node.Inputs, Is.Empty); + Assert.That(node.MetadataInputs, Is.Empty); + } + + private sealed class ThrowingHookNode : ValueNode + { + private readonly List _metadataInputs = []; + + public IReadOnlyList MetadataInputs => _metadataInputs; + + public bool ThrowOnAdd { get; set; } + + public bool ThrowOnRemove { get; set; } + + public bool ThrowOnClear { get; set; } + + protected override void OnInputAdded(AudioNode input, int index) + { + _metadataInputs.Insert(index, input); + if (ThrowOnAdd) + { + _metadataInputs.RemoveAt(index); + throw new InvalidOperationException("Add hook failure."); + } + } + + protected override void OnInputRemoved(AudioNode input, int index) + { + AudioNode metadata = _metadataInputs[index]; + _metadataInputs.RemoveAt(index); + if (ThrowOnRemove) + { + _metadataInputs.Insert(index, metadata); + throw new InvalidOperationException("Remove hook failure."); + } + } + + protected override void OnInputsCleared() + { + AudioNode[] metadata = [.. _metadataInputs]; + _metadataInputs.Clear(); + if (ThrowOnClear) + { + _metadataInputs.AddRange(metadata); + throw new InvalidOperationException("Clear hook failure."); + } + } + } + + private class ValueNode : AudioNode + { + public override AudioBuffer Process(AudioProcessContext context) + => new(context.SampleRate, 2, context.GetSampleCount()); + } +} diff --git a/tests/Beutl.UnitTests/Engine/Audio/AudioProcessContextTests.cs b/tests/Beutl.UnitTests/Engine/Audio/AudioProcessContextTests.cs index eeaa101295..5745d4b996 100644 --- a/tests/Beutl.UnitTests/Engine/Audio/AudioProcessContextTests.cs +++ b/tests/Beutl.UnitTests/Engine/Audio/AudioProcessContextTests.cs @@ -80,6 +80,57 @@ public void GetSampleCount_Static_JustBelowSampleBoundary_DoesNotOverCount() Assert.That(AudioProcessContext.GetSampleCount(range, 44100), Is.EqualTo(1)); } + [TestCase(1, 44100)] + [TestCase(128, 44100)] + [TestCase(3087, 44100)] + [TestCase(4096, 44100)] + [TestCase(128, 48000)] + [TestCase(4096, 48000)] + public void GetDurationForSampleCount_RoundTripsToTheExactSampleCount(int sampleCount, int sampleRate) + { + TimeSpan duration = AudioProcessContext.GetDurationForSampleCount(sampleCount, sampleRate); + + Assert.That( + AudioProcessContext.GetSampleCount(new TimeRange(TimeSpan.Zero, duration), sampleRate), + Is.EqualTo(sampleCount)); + } + + [Test] + public void GetDurationForSampleCount_ZeroSamples_ReturnsZero() + { + Assert.That( + AudioProcessContext.GetDurationForSampleCount(0, 44100), + Is.EqualTo(TimeSpan.Zero)); + } + + [Test] + public void GetDurationForSampleCount_NegativeSampleCount_Throws() + { + var ex = Assert.Throws( + () => AudioProcessContext.GetDurationForSampleCount(-1, 44100)); + + Assert.That(ex!.ParamName, Is.EqualTo("sampleCount")); + } + + [TestCase(0)] + [TestCase(-1)] + public void GetDurationForSampleCount_NonPositiveSampleRate_Throws(int sampleRate) + { + var ex = Assert.Throws( + () => AudioProcessContext.GetDurationForSampleCount(1, sampleRate)); + + Assert.That(ex!.ParamName, Is.EqualTo("sampleRate")); + } + + [Test] + public void GetDurationForSampleCount_UnrepresentableRate_Throws() + { + var ex = Assert.Throws( + () => AudioProcessContext.GetDurationForSampleCount(1, int.MaxValue)); + + Assert.That(ex!.ParamName, Is.EqualTo("sampleRate")); + } + [TestCase(0)] [TestCase(-1)] [TestCase(int.MinValue)] diff --git a/tests/Beutl.UnitTests/Engine/Audio/ComposerTests.cs b/tests/Beutl.UnitTests/Engine/Audio/ComposerTests.cs index ab48cdc1e8..1199d1cbbe 100644 --- a/tests/Beutl.UnitTests/Engine/Audio/ComposerTests.cs +++ b/tests/Beutl.UnitTests/Engine/Audio/ComposerTests.cs @@ -10,6 +10,23 @@ namespace Beutl.UnitTests.Engine.Audio; public class ComposerTests { + [Test] + public void Compose_EligibilityNotCaptured_Throws() + { + var range = new TimeRange(TimeSpan.Zero, TimeSpan.FromSeconds(1)); + var frame = new CompositionFrame( + ImmutableArray.Empty, + range, + default, + null); + using var composer = new Composer(); + + var exception = Assert.Throws(() => composer.Compose(range, frame)); + + Assert.That(exception!.Message, Does.Contain("eligibility snapshot")); + Assert.That(composer.IsAudioRendering, Is.False); + } + [Test] public void Compose_EmptyFrame_ReturnsSilentBufferWithCeilingSampleCount() { @@ -21,7 +38,11 @@ public void Compose_EmptyFrame_ReturnsSilentBufferWithCeilingSampleCount() const int sampleRate = 44100; var oneSampleTicksFloor = TimeSpan.TicksPerSecond / sampleRate; var range = new TimeRange(TimeSpan.Zero, TimeSpan.FromTicks(oneSampleTicksFloor + 1)); - var frame = new CompositionFrame(ImmutableArray.Empty, range, default); + var frame = new CompositionFrame( + ImmutableArray.Empty, + range, + default, + CompositionEligibility.Empty); using var composer = new Composer { SampleRate = sampleRate }; using AudioBuffer? buffer = composer.Compose(range, frame); @@ -38,7 +59,11 @@ public void Compose_EmptyFrame_IntegerSecondDuration_MatchesGetSampleCount() { const int sampleRate = 48000; var range = new TimeRange(TimeSpan.Zero, TimeSpan.FromSeconds(1)); - var frame = new CompositionFrame(ImmutableArray.Empty, range, default); + var frame = new CompositionFrame( + ImmutableArray.Empty, + range, + default, + CompositionEligibility.Empty); using var composer = new Composer { SampleRate = sampleRate }; using AudioBuffer? buffer = composer.Compose(range, frame); diff --git a/tests/Beutl.UnitTests/Engine/Audio/LimiterTailSound.cs b/tests/Beutl.UnitTests/Engine/Audio/LimiterTailSound.cs new file mode 100644 index 0000000000..581a19522a --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Audio/LimiterTailSound.cs @@ -0,0 +1,63 @@ +using Beutl.Audio; +using Beutl.Audio.Effects; +using Beutl.Audio.Graph; +using Beutl.Audio.Graph.Nodes; +using Beutl.Engine; +using Beutl.Media; +using Beutl.Media.Source; + +namespace Beutl.UnitTests.Engine.Audio; + +// A self-contained Sound (no media file) whose graph is a clip-local sine leaf feeding a lookahead +// limiter, so a Composer that composes it across two windows exercises the limiter tail held at the +// clip boundary. Top-level partial so the resource source generator emits its Resource. +public sealed partial class LimiterTailSound : Sound +{ + public LimiterTailSound() => ScanProperties(); + + public float LookaheadMs { get; set; } = 5f; + + public override void Compose(AudioContext context, Sound.Resource resource) + { + var source = context.AddNode(new ClipLocalSineNode(context.SampleRate)); + var limiter = context.AddNode(new LimiterNode + { + Threshold = Property.CreateAnimatable(LimiterParameters.MaxThresholdDb), + Release = Property.CreateAnimatable(LimiterParameters.DefaultReleaseMs), + Lookahead = Property.CreateAnimatable(LookaheadMs), + MakeupGain = Property.CreateAnimatable(0f), + }); + context.Connect(source, limiter); + + var clip = context.CreateClipNode(TimeRange.Start, TimeRange.Duration); + context.Connect(limiter, clip); + context.MarkAsOutput(clip); + } + + // A clip-local sine keyed to the absolute sample index, returning silence for reads past the clip + // (the precondition Flush relies on). Mirrors the RangeSineNode used by the node-level tests. + private sealed class ClipLocalSineNode(int sampleRate) : AudioNode + { + public override AudioBuffer Process(AudioProcessContext context) + { + int count = context.GetSampleCount(); + var buffer = new AudioBuffer(sampleRate, 2, count); + long startIndex = AudioMath.TimeToSampleIndex(context.TimeRange.Start, sampleRate); + for (int ch = 0; ch < 2; ch++) + { + var data = buffer.GetChannelData(ch); + for (int i = 0; i < count; i++) + { + data[i] = 0.25f * MathF.Sin(2f * MathF.PI * 200f * (startIndex + i) / sampleRate); + } + } + + return buffer; + } + } + + public partial class Resource + { + public override SoundSource.Resource? GetSoundSource() => null; + } +} diff --git a/tests/Beutl.UnitTests/Engine/Audio/SpeedNodeTests.cs b/tests/Beutl.UnitTests/Engine/Audio/SpeedNodeTests.cs index 32d15d3cbc..8127e60244 100644 --- a/tests/Beutl.UnitTests/Engine/Audio/SpeedNodeTests.cs +++ b/tests/Beutl.UnitTests/Engine/Audio/SpeedNodeTests.cs @@ -162,6 +162,34 @@ public void ProcessAnimatedSpeed_ConstantHalfSpeed_IsContinuousAndCorrectAcrossC } } + [Test] + public void ProcessAnimatedSpeed_At44100Hz_RequestsEverySourceSampleExactlyOnce() + { + const int sampleRate = 44100; + var input = new SourceRequestRecordingNode(sampleRate); + using var node = new SpeedNode { Speed = AnimatedSpeed(100f, 100f, 10.0) }; + node.AddInput(input); + + using AudioBuffer _ = node.Process( + new AudioProcessContext( + new TimeRange(TimeSpan.Zero, TimeSpan.FromSeconds(1)), + sampleRate, + new AnimationSampler(), + null)); + + Assert.That(input.Requests.Count, Is.GreaterThan(128), + "The render must cross the fractional-tick drift boundary."); + + long expectedStart = 0; + for (int i = 0; i < input.Requests.Count; i++) + { + (long start, int count) = input.Requests[i]; + Assert.That(start, Is.EqualTo(expectedStart), + $"Source request {i} repeated or skipped a sample."); + expectedStart += count; + } + } + // Deterministic finite stereo source: a ramp for the first _length samples, silence beyond. Models // SourceNode zero-filling past end-of-source. private sealed class FiniteRampInputNode : AudioNode @@ -194,6 +222,18 @@ public override AudioBuffer Process(AudioProcessContext context) } } + private sealed class SourceRequestRecordingNode(int sampleRate) : AudioNode + { + public List<(long Start, int Count)> Requests { get; } = []; + + public override AudioBuffer Process(AudioProcessContext context) + { + int count = context.GetSampleCount(); + Requests.Add((AudioMath.TimeToSampleIndex(context.TimeRange.Start, sampleRate), count)); + return new AudioBuffer(sampleRate, 2, count); + } + } + // Past end-of-source the stream must decay to silence, not loop, hold, or emit stale resampler // data. Half speed maps the 300-sample source onto output samples [0, 600). [Test] diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RendererExceptionSafetyTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RendererExceptionSafetyTests.cs index 6b6d14edb5..53108605f0 100644 --- a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RendererExceptionSafetyTests.cs +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RendererExceptionSafetyTests.cs @@ -65,7 +65,8 @@ private static CompositionFrame CreateFrame(params RenderNodeOperation[] operati return new CompositionFrame( ImmutableArray.Create(resource), new TimeRange(TimeSpan.Zero, TimeSpan.FromSeconds(1)), - new PixelSize(16, 16)); + new PixelSize(16, 16), + new CompositionEligibility([drawable])); } private static RenderNodeOperation CreateOperation( diff --git a/tests/Beutl.UnitTests/ProjectSystem/SceneCompositorTests.cs b/tests/Beutl.UnitTests/ProjectSystem/SceneCompositorTests.cs index d60ee9e778..704e7dcf30 100644 --- a/tests/Beutl.UnitTests/ProjectSystem/SceneCompositorTests.cs +++ b/tests/Beutl.UnitTests/ProjectSystem/SceneCompositorTests.cs @@ -89,9 +89,39 @@ public void EvaluateAudio_DisabledElement_IsExcluded() CompositionFrame frame = compositor.EvaluateAudio( new TimeRange(TimeSpan.Zero, TimeSpan.FromSeconds(1))); + CompositionEligibility eligibility = frame.Eligibility + ?? throw new AssertionException("Audio evaluation must capture eligibility."); Assert.That(frame.Objects.Length, Is.EqualTo(1)); Assert.That(frame.Objects[0].GetOriginal(), Is.SameAs(enabled.Objects[0])); + Assert.That(eligibility.Contains(enabled.Objects[0]), Is.True); + Assert.That(eligibility.Contains(disabled.Objects[0]), Is.False); + } + finally + { + if (Directory.Exists(basePath)) Directory.Delete(basePath, recursive: true); + } + } + + [Test] + public void EvaluateAudio_OutOfRangeElement_RemainsEligible() + { + string basePath = GetTempPath(); + try + { + Scene scene = CreateScene(basePath); + Element element = CreateElement(basePath, isEnabled: true, new TestAudioObject()); + scene.Children.Add(element); + using var compositor = new SceneCompositor(scene); + + CompositionFrame frame = compositor.EvaluateAudio( + new TimeRange(TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(1))); + CompositionEligibility eligibility = frame.Eligibility + ?? throw new AssertionException("Audio evaluation must capture eligibility."); + + Assert.That(frame.Objects, Is.Empty); + Assert.That(eligibility.Contains(element.Objects[0]), Is.True, + "Eligibility ignores time intersection so Composer can identify a natural clip end."); } finally { @@ -311,9 +341,13 @@ public void EvaluateAudio_AudioMutedLayer_ExcludedFromAudio() CompositionFrame frame = compositor.EvaluateAudio( new TimeRange(TimeSpan.Zero, TimeSpan.FromSeconds(1))); + CompositionEligibility eligibility = frame.Eligibility + ?? throw new AssertionException("Audio evaluation must capture eligibility."); Assert.That(frame.Objects.Length, Is.EqualTo(1)); Assert.That(frame.Objects[0].GetOriginal(), Is.SameAs(z1.Objects[0])); + Assert.That(eligibility.Contains(z0.Objects[0]), Is.False); + Assert.That(eligibility.Contains(z1.Objects[0]), Is.True); } finally { @@ -321,6 +355,17 @@ public void EvaluateAudio_AudioMutedLayer_ExcludedFromAudio() } } + [Test] + public void CompositionEligibility_UsesReferenceIdentity() + { + var first = new ValueEqualEligibilityObject(); + var second = new ValueEqualEligibilityObject(); + var eligibility = new CompositionEligibility([first]); + + Assert.That(eligibility.Contains(first), Is.True); + Assert.That(eligibility.Contains(second), Is.False); + } + [Test] public void EvaluateGraphics_AudioMutedLayer_StillComposesGraphics() { @@ -338,6 +383,8 @@ public void EvaluateGraphics_AudioMutedLayer_StillComposesGraphics() Assert.That(frame.Objects.Length, Is.EqualTo(1)); Assert.That(frame.Objects[0].GetOriginal(), Is.SameAs(z0.Objects[0])); + Assert.That(frame.Eligibility, Is.Null, + "Graphics frames do not consume eligibility and must not allocate a scene-wide snapshot."); } finally { @@ -639,6 +686,14 @@ private class TestAudioObject : EngineObject { public override CompositionTarget GetCompositionTarget() => CompositionTarget.Audio; } + + [Beutl.Engine.SuppressResourceClassGeneration] + private sealed class ValueEqualEligibilityObject : EngineObject + { + public override bool Equals(object? obj) => obj is ValueEqualEligibilityObject; + + public override int GetHashCode() => 0; + } } internal sealed partial class SceneCompositorContextCaptureDrawable : Drawable