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