From 877cee5c3eab1ada6b6c8dc3238beabf828715e7 Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Wed, 24 Jun 2026 01:35:50 +0900 Subject: [PATCH 01/24] feat(engine): report audio effect/node latency for lookahead effects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lookahead-bearing audio effects (the Limiter's lookahead delay line) make the rendered clip lag and drop its tail by `lookaheadSamples`, but the audio graph had no way to report that latency. This adds a report-only API so the pipeline (and plugin hosts) can discover per-node and per-effect latency; it is the prerequisite for any future automatic compensation. - AudioNode.GetLatencySamples(sampleRate) (virtual, default 0) reports a node's own latency; GetTotalLatencySamples aggregates the feeding path as local + max(input totals), so a linear cascade sums and a mixer fan-in takes the slowest branch (both virtual/overridable for plugin authors). - AudioEffect.GetLatencySamples(sampleRate) lets a host query latency before a graph node exists; AudioEffectGroup sums its enabled children. - LimiterNode/LimiterEffect override it via a new LimiterParameters.ToLatencySamples helper that mirrors the runtime clamp. Report-only and behavior-preserving: the new methods are side-effect-free and are not called from Process/Compose, so render output is unchanged. Actual compensation (range/flush) is deferred — the board's range-expansion proposal is incompatible with the stateful chunked pull graph (it would trigger spurious LimiterNode resets and double-process overlap at every chunk boundary), so it needs a dedicated render-session layer. Refs: Project #9 "AI Review" item "Audio graph: latency reporting/compensation API for lookahead-bearing AudioEffects" Claude-Session: https://claude.ai/code/session_01PJ9eDL52jpiM1t5oDRCLM2 --- src/Beutl.Engine/Audio/Effects/AudioEffect.cs | 12 + .../Audio/Effects/AudioEffectGroup.cs | 11 + .../Audio/Effects/LimiterEffect.cs | 9 + .../Audio/Effects/LimiterParameters.cs | 17 + src/Beutl.Engine/Audio/Graph/AudioNode.cs | 33 ++ .../Audio/Graph/Nodes/LimiterNode.cs | 8 + .../Engine/Audio/AudioLatencyTests.cs | 292 ++++++++++++++++++ 7 files changed, 382 insertions(+) create mode 100644 tests/Beutl.UnitTests/Engine/Audio/AudioLatencyTests.cs diff --git a/src/Beutl.Engine/Audio/Effects/AudioEffect.cs b/src/Beutl.Engine/Audio/Effects/AudioEffect.cs index 7f9476a450..ad7b3e5eb5 100644 --- a/src/Beutl.Engine/Audio/Effects/AudioEffect.cs +++ b/src/Beutl.Engine/Audio/Effects/AudioEffect.cs @@ -10,4 +10,16 @@ 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. + /// + /// 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..104234fc54 100644 --- a/src/Beutl.Engine/Audio/Effects/AudioEffectGroup.cs +++ b/src/Beutl.Engine/Audio/Effects/AudioEffectGroup.cs @@ -20,4 +20,15 @@ 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 (see CreateNode), so their latencies add. Mirrors + // CreateNode: filters on each child's IsEnabled but does not gate on the group's own IsEnabled — + // callers decide whether to skip a disabled group (Sound only builds the chain when enabled). + public override int GetLatencySamples(int sampleRate) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(sampleRate); + + return Children.Where(item => item.IsEnabled) + .Sum(item => item.GetLatencySamples(sampleRate)); + } } diff --git a/src/Beutl.Engine/Audio/Effects/LimiterEffect.cs b/src/Beutl.Engine/Audio/Effects/LimiterEffect.cs index 776be0322d..cf9cb610e6 100644 --- a/src/Beutl.Engine/Audio/Effects/LimiterEffect.cs +++ b/src/Beutl.Engine/Audio/Effects/LimiterEffect.cs @@ -94,4 +94,13 @@ 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; + + return 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/AudioNode.cs b/src/Beutl.Engine/Audio/Graph/AudioNode.cs index 223d404a99..6a5f8fd6a2 100644 --- a/src/Beutl.Engine/Audio/Graph/AudioNode.cs +++ b/src/Beutl.Engine/Audio/Graph/AudioNode.cs @@ -30,6 +30,39 @@ public void ClearInputs() public abstract AudioBuffer Process(AudioProcessContext context); + /// + /// 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. + /// + /// 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. + /// + public virtual int GetTotalLatencySamples(int sampleRate) + { + int upstream = 0; + foreach (AudioNode input in _inputs) + { + int total = input.GetTotalLatencySamples(sampleRate); + if (total > upstream) + upstream = total; + } + + return GetLatencySamples(sampleRate) + upstream; + } + protected virtual void Dispose(bool disposing) { if (!_disposed) diff --git a/src/Beutl.Engine/Audio/Graph/Nodes/LimiterNode.cs b/src/Beutl.Engine/Audio/Graph/Nodes/LimiterNode.cs index 0e82bdf788..0e5ff5294d 100644 --- a/src/Beutl.Engine/Audio/Graph/Nodes/LimiterNode.cs +++ b/src/Beutl.Engine/Audio/Graph/Nodes/LimiterNode.cs @@ -131,6 +131,14 @@ public override AudioBuffer Process(AudioProcessContext context) return output; } + /// + /// Reports the lookahead delay this limiter applies, in samples at . + /// For an animated the actual delay varies per sample; the reported value + /// reflects the property's current value only. + /// + public override int GetLatencySamples(int 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 diff --git a/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyTests.cs b/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyTests.cs new file mode 100644 index 0000000000..eab3565e62 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyTests.cs @@ -0,0 +1,292 @@ +using Beutl.Animation; +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 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_IgnoresGroupOwnIsEnabled() + { + // The group's own IsEnabled does not gate the report (it mirrors CreateNode, which only filters + // children); the caller decides whether to skip a disabled group, so a disabled group still + // sums its enabled children. + var group = new AudioEffectGroup { IsEnabled = false }; + 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 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(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)); + } +} From 6c63c834338b65fc6c9697021b99a5cb21a04159 Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Wed, 24 Jun 2026 01:48:53 +0900 Subject: [PATCH 02/24] test(engine): guard GetTotalLatencySamples sampleRate at the entry point Validate sampleRate in GetTotalLatencySamples itself rather than relying on the indirect throw via GetLatencySamples, so an override that folds the upstream recursion before delegating still honors the contract. Cover the aggregating entry point in the non-positive-rate test. Addresses Copilot review feedback on PR #1998. Claude-Session: https://claude.ai/code/session_01PJ9eDL52jpiM1t5oDRCLM2 --- src/Beutl.Engine/Audio/Graph/AudioNode.cs | 5 +++++ tests/Beutl.UnitTests/Engine/Audio/AudioLatencyTests.cs | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/src/Beutl.Engine/Audio/Graph/AudioNode.cs b/src/Beutl.Engine/Audio/Graph/AudioNode.cs index 6a5f8fd6a2..23f151db2f 100644 --- a/src/Beutl.Engine/Audio/Graph/AudioNode.cs +++ b/src/Beutl.Engine/Audio/Graph/AudioNode.cs @@ -50,8 +50,13 @@ public virtual int GetLatencySamples(int sampleRate) /// Override to impose a different upstream fold (e.g. a weighted-sum mixer). Requires an acyclic /// input graph, the same precondition already relies on. /// + /// 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) { diff --git a/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyTests.cs b/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyTests.cs index eab3565e62..815e38327d 100644 --- a/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyTests.cs +++ b/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyTests.cs @@ -288,5 +288,10 @@ public void GetLatencySamples_NonPositiveSampleRate_Throws(int 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)); } } From 8e1255e6cff70a091f1b0dddf99b2cf74cbd16d6 Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Wed, 24 Jun 2026 06:11:12 +0900 Subject: [PATCH 03/24] feat(engine): compensate lookahead tail loss via a node Flush contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recovers the clip-tail audio that a lookahead effect (the Limiter) leaves stuck in its delay line. Adds AudioNode.Flush(context): a node drains the latency it still holds, treating the upstream source as exhausted (a leaf returns silence), so a trimmed clip never bleeds real audio. The default passes a single input's drain through unchanged; LimiterNode overrides it to push its lookahead delay line out through the same path Process uses. ClipNode, on the window that reaches the clip's true end, calls Inputs[0].Flush over the clip-local range [Duration, Duration+L) — contiguous with the main slice, so the cached effect state never resets — and appends the drained tail into the trailing pad the window already reserves. Crucially this does NOT expand the Composer's requested range: doing so pushes consecutive preview windows outside LimiterNode's 1-tick contiguity tolerance and triggers a spurious Reset + double-processed overlap at every chunk boundary (verified wrong in design review). Compensation is a node contract instead, so exact window tiling and cached DSP state are preserved. Scope: tail recovery only. Leading lookahead silence is left intact (removing it would shift the clip and break A/V sync — documented-intended behavior). Refs: Project #9 "AI Review" item "Audio graph: latency reporting/compensation API for lookahead-bearing AudioEffects" Claude-Session: https://claude.ai/code/session_01PJ9eDL52jpiM1t5oDRCLM2 --- src/Beutl.Engine/Audio/Graph/AudioNode.cs | 19 ++ .../Audio/Graph/Nodes/ClipNode.cs | 38 +++ .../Audio/Graph/Nodes/LimiterNode.cs | 25 ++ .../Audio/AudioLatencyCompensationTests.cs | 231 ++++++++++++++++++ 4 files changed, 313 insertions(+) create mode 100644 tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs diff --git a/src/Beutl.Engine/Audio/Graph/AudioNode.cs b/src/Beutl.Engine/Audio/Graph/AudioNode.cs index 23f151db2f..05e8383b1f 100644 --- a/src/Beutl.Engine/Audio/Graph/AudioNode.cs +++ b/src/Beutl.Engine/Audio/Graph/AudioNode.cs @@ -30,6 +30,25 @@ public void ClearInputs() public abstract AudioBuffer Process(AudioProcessContext context); + /// + /// 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. + /// The default passes a single input's drain through unchanged; latency-bearing nodes override. + /// + public virtual AudioBuffer Flush(AudioProcessContext context) + { + ArgumentNullException.ThrowIfNull(context); + + if (_inputs.Count == 1) + return _inputs[0].Flush(context); + + return new AudioBuffer(context.SampleRate, 2, context.GetSampleCount()); + } + /// /// 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 diff --git a/src/Beutl.Engine/Audio/Graph/Nodes/ClipNode.cs b/src/Beutl.Engine/Audio/Graph/Nodes/ClipNode.cs index c92677511a..627485d3bc 100644 --- a/src/Beutl.Engine/Audio/Graph/Nodes/ClipNode.cs +++ b/src/Beutl.Engine/Audio/Graph/Nodes/ClipNode.cs @@ -52,6 +52,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 +71,32 @@ public override AudioBuffer Process(AudioProcessContext context) throw; } } + + // 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) + { + 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, TimeSpan.FromSeconds((double)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); + } + } } diff --git a/src/Beutl.Engine/Audio/Graph/Nodes/LimiterNode.cs b/src/Beutl.Engine/Audio/Graph/Nodes/LimiterNode.cs index 0e5ff5294d..f0585d987a 100644 --- a/src/Beutl.Engine/Audio/Graph/Nodes/LimiterNode.cs +++ b/src/Beutl.Engine/Audio/Graph/Nodes/LimiterNode.cs @@ -67,6 +67,31 @@ public override AudioBuffer Process(AudioProcessContext context) using var input = Inputs[0].Process(context) ?? throw new InvalidOperationException("LimiterNode: upstream Process returned null."); + return ProcessInput(input, context); + } + + /// + /// Drains the lookahead delay line by feeding the upstream flush block (silence past the clip end) + /// through the same path uses, so the tail samples still held in the delay + /// line are emitted. Reuses the cached state contiguously, so it must run right after the terminal + /// chunk and never resets. + /// + public override AudioBuffer Flush(AudioProcessContext context) + { + ArgumentNullException.ThrowIfNull(context); + + if (Inputs.Count != 1) + throw new InvalidOperationException( + $"LimiterNode requires exactly one input but has {Inputs.Count}."); + + using var input = Inputs[0].Flush(context) + ?? throw new InvalidOperationException("LimiterNode: upstream Flush returned null."); + + return ProcessInput(input, context); + } + + private AudioBuffer ProcessInput(AudioBuffer input, AudioProcessContext context) + { if (input.SampleRate != context.SampleRate) throw new InvalidOperationException( $"LimiterNode: sample rate mismatch. context={context.SampleRate}, input={input.SampleRate}."); diff --git a/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs b/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs new file mode 100644 index 0000000000..f711d06138 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs @@ -0,0 +1,231 @@ +using Beutl.Animation; +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 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 exactly covers the whole clip: it reaches the clip's true end, so ClipNode + // drains the limiter tail into the trailing samples. + using var output = clip.Process(Context(TimeSpan.Zero, clipSamples)); + + var data = output.GetChannelData(0); + bool tailNonZero = false; + for (int i = clipSamples - L; i < clipSamples; i++) + { + if (MathF.Abs(data[i]) > 1e-5f) { tailNonZero = true; break; } + } + + Assert.That(tailNonZero, Is.True, + "The clip's final L samples, normally lost in the delay line, are recovered by the tail flush."); + } + + [Test] + public void ClipNode_ZeroLookahead_IsNoOp() + { + const int clipSamples = 2048; + 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); + // A mid-clip window (does not reach the end) never drains; compare its overlapping region. + using var reference = clipB.Process(Context(TimeSpan.Zero, clipSamples)); + + var a = withDrain.GetChannelData(0); + var b = reference.GetChannelData(0); + for (int i = 0; i < clipSamples; i++) + { + Assert.That(a[i], Is.EqualTo(b[i]).Within(1e-6f), $"L==0 drain must not perturb sample {i}."); + } + } + + // 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; + } + } +} From 90bf840cc24acd8c677feb805611270c73326668 Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Wed, 24 Jun 2026 06:36:25 +0900 Subject: [PATCH 04/24] fix(engine): apply downstream processing and fan-in to flushed tails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the CodeRabbit/Codex review of the Flush compensation: the default Flush bypassed the node's own processing, hardcoded stereo, dropped fan-in branches, and under-reported animated lookahead. Unify everything on a ProcessTail(input, context) seam: Process feeds it real upstream audio, Flush feeds it the drained tail, so a transforming node shapes the tail exactly like the body. The base ProcessTail is pass-through, so the zero-processing path stays byte-identical. - AudioNode: default Flush now runs the upstream drain through ProcessTail (so a downstream Equalizer/Compressor/Gain processes the recovered tail); zero-input silence matches the last processed channel count instead of hardcoded stereo; a fan-in node without a Flush override now throws rather than silently dropping tails. - Limiter/Gain/Equalizer/Compressor/Delay: Process bodies move into ProcessTail (LimiterNode's bespoke Flush is gone — it is now just a ProcessTail override). - MixerNode: overrides Flush to drain and mix every branch, recovering a lookahead tail held in any fan-in branch. - LimiterNode.GetLatencySamples: reports the worst-case lookahead when animated, so the drain reserves enough room when automation peaks near the clip end. - SourceNode records its stereo output so the flush silence matches. ClipNode's chunk-boundary-alignment tail loss (block ending exactly at the clip end) is documented as a known limitation; the only fixes are out of scope (range expansion resets the limiter; an oversized terminal buffer breaks the output contract). Refs: Project #9 "AI Review" item "Audio graph: latency reporting/compensation API for lookahead-bearing AudioEffects" Claude-Session: https://claude.ai/code/session_01PJ9eDL52jpiM1t5oDRCLM2 --- src/Beutl.Engine/Audio/Graph/AudioNode.cs | 43 +++++++- .../Audio/Graph/Nodes/ClipNode.cs | 6 + .../Audio/Graph/Nodes/DelayNode.cs | 7 +- .../Audio/Graph/Nodes/DynamicsNode.cs | 6 +- .../Audio/Graph/Nodes/EqualizerNode.cs | 5 +- .../Audio/Graph/Nodes/GainNode.cs | 5 +- .../Audio/Graph/Nodes/LimiterNode.cs | 36 ++---- .../Audio/Graph/Nodes/MixerNode.cs | 17 ++- .../Audio/Graph/Nodes/SourceNode.cs | 2 + .../Audio/AudioLatencyCompensationTests.cs | 103 ++++++++++++++++++ 10 files changed, 197 insertions(+), 33 deletions(-) diff --git a/src/Beutl.Engine/Audio/Graph/AudioNode.cs b/src/Beutl.Engine/Audio/Graph/AudioNode.cs index 05e8383b1f..86f85c7711 100644 --- a/src/Beutl.Engine/Audio/Graph/AudioNode.cs +++ b/src/Beutl.Engine/Audio/Graph/AudioNode.cs @@ -30,6 +30,29 @@ public void ClearInputs() 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; feeds it the drained tail, so a transforming node processes the tail + /// the same way it processes the body. The default is pass-through (returns + /// unchanged), keeping the zero-processing path byte-identical. A node that returns a fresh buffer + /// takes ownership of and disposes it, exactly as its Process already does. + /// + protected virtual AudioBuffer ProcessTail(AudioBuffer input, AudioProcessContext context) => 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. + 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. @@ -37,16 +60,30 @@ public void ClearInputs() /// 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. - /// The default passes a single input's drain through unchanged; latency-bearing nodes override. + /// 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 _inputs[0].Flush(context); + { + AudioBuffer drained = _inputs[0].Flush(context); + AudioBuffer result = ProcessTail(drained, context); + // Pass-through ProcessTail hands back the same instance, which we must not dispose since we + // return it; a transforming ProcessTail already consumed `drained` and returns a fresh one. + if (!ReferenceEquals(result, drained)) + drained.Dispose(); + + return result; + } - return new AudioBuffer(context.SampleRate, 2, context.GetSampleCount()); + throw new InvalidOperationException( + $"{GetType().Name} has {_inputs.Count} inputs; override Flush to drain and merge them."); } /// diff --git a/src/Beutl.Engine/Audio/Graph/Nodes/ClipNode.cs b/src/Beutl.Engine/Audio/Graph/Nodes/ClipNode.cs index 627485d3bc..cb54af03ed 100644 --- a/src/Beutl.Engine/Audio/Graph/Nodes/ClipNode.cs +++ b/src/Beutl.Engine/Audio/Graph/Nodes/ClipNode.cs @@ -77,6 +77,12 @@ public override AudioBuffer Process(AudioProcessContext context) // 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; diff --git a/src/Beutl.Engine/Audio/Graph/Nodes/DelayNode.cs b/src/Beutl.Engine/Audio/Graph/Nodes/DelayNode.cs index d9936e5ed6..fbedf08547 100644 --- a/src/Beutl.Engine/Audio/Graph/Nodes/DelayNode.cs +++ b/src/Beutl.Engine/Audio/Graph/Nodes/DelayNode.cs @@ -25,8 +25,13 @@ 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); + } + + protected override AudioBuffer ProcessTail(AudioBuffer input, AudioProcessContext context) + { // 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..5471829798 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); + } + + protected override AudioBuffer ProcessTail(AudioBuffer input, AudioProcessContext context) + { 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..c78a698139 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); + } + protected override AudioBuffer ProcessTail(AudioBuffer input, AudioProcessContext context) + { // 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..edfcb829ff 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); + } + protected override AudioBuffer ProcessTail(AudioBuffer input, AudioProcessContext context) + { 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 f0585d987a..adc17d73d5 100644 --- a/src/Beutl.Engine/Audio/Graph/Nodes/LimiterNode.cs +++ b/src/Beutl.Engine/Audio/Graph/Nodes/LimiterNode.cs @@ -67,30 +67,13 @@ public override AudioBuffer Process(AudioProcessContext context) using var input = Inputs[0].Process(context) ?? throw new InvalidOperationException("LimiterNode: upstream Process returned null."); - return ProcessInput(input, context); + return ProcessTail(input, context); } - /// - /// Drains the lookahead delay line by feeding the upstream flush block (silence past the clip end) - /// through the same path uses, so the tail samples still held in the delay - /// line are emitted. Reuses the cached state contiguously, so it must run right after the terminal - /// chunk and never resets. - /// - public override AudioBuffer Flush(AudioProcessContext context) - { - ArgumentNullException.ThrowIfNull(context); - - if (Inputs.Count != 1) - throw new InvalidOperationException( - $"LimiterNode requires exactly one input but has {Inputs.Count}."); - - using var input = Inputs[0].Flush(context) - ?? throw new InvalidOperationException("LimiterNode: upstream Flush returned null."); - - return ProcessInput(input, context); - } - - private AudioBuffer ProcessInput(AudioBuffer input, AudioProcessContext context) + // 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) { if (input.SampleRate != context.SampleRate) throw new InvalidOperationException( @@ -158,11 +141,14 @@ private AudioBuffer ProcessInput(AudioBuffer input, AudioProcessContext context) /// /// Reports the lookahead delay this limiter applies, in samples at . - /// For an animated the actual delay varies per sample; the reported value - /// reflects the property's current value only. + /// 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) - => LimiterParameters.ToLatencySamples(Lookahead.CurrentValue, sampleRate); + => Lookahead.Animation != null + ? LimiterParameters.ToLatencySamples(LimiterParameters.MaxLookaheadMs, sampleRate) + : LimiterParameters.ToLatencySamples(Lookahead.CurrentValue, sampleRate); private void InitializeBuffers(int sampleRate, int channelCount) { diff --git a/src/Beutl.Engine/Audio/Graph/Nodes/MixerNode.cs b/src/Beutl.Engine/Audio/Graph/Nodes/MixerNode.cs index 62ef684471..fa9bc254a3 100644 --- a/src/Beutl.Engine/Audio/Graph/Nodes/MixerNode.cs +++ b/src/Beutl.Engine/Audio/Graph/Nodes/MixerNode.cs @@ -14,6 +14,21 @@ public float[] Gains } 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 branch is recovered (the base Flush's single-input path cannot reach here). + 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."); @@ -24,7 +39,7 @@ public override AudioBuffer Process(AudioProcessContext context) { for (int i = 0; i < Inputs.Count; i++) { - buffers[i] = Inputs[i].Process(context); + buffers[i] = drain ? Inputs[i].Flush(context) : Inputs[i].Process(context); } // Validate all buffers have the same format 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/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs b/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs index f711d06138..6c9cdcfe01 100644 --- a/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs +++ b/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs @@ -1,4 +1,5 @@ using Beutl.Animation; +using Beutl.Animation.Easings; using Beutl.Audio; using Beutl.Audio.Effects; using Beutl.Audio.Graph; @@ -206,6 +207,108 @@ public void ClipNode_ZeroLookahead_IsNoOp() } } + [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."); + } + } + + [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 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."); + } + // 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). From 91559e9f14c4c7e597f7d85da4cf8ba8f8db3e05 Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Wed, 24 Jun 2026 06:48:51 +0900 Subject: [PATCH 05/24] fix(engine): dispose drained buffer if ProcessTail throws in Flush The single-input Flush path disposed the drained buffer only after a successful ProcessTail, leaking it on a throw. Wrap ProcessTail in try/catch and dispose the drain on failure; Dispose is idempotent, so a transforming node that already consumed the buffer is unaffected. Addresses CodeRabbit review on PR #1998. Claude-Session: https://claude.ai/code/session_01PJ9eDL52jpiM1t5oDRCLM2 --- src/Beutl.Engine/Audio/Graph/AudioNode.cs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/Beutl.Engine/Audio/Graph/AudioNode.cs b/src/Beutl.Engine/Audio/Graph/AudioNode.cs index 86f85c7711..61e2eae01d 100644 --- a/src/Beutl.Engine/Audio/Graph/AudioNode.cs +++ b/src/Beutl.Engine/Audio/Graph/AudioNode.cs @@ -73,7 +73,19 @@ public virtual AudioBuffer Flush(AudioProcessContext context) if (_inputs.Count == 1) { AudioBuffer drained = _inputs[0].Flush(context); - AudioBuffer result = ProcessTail(drained, context); + AudioBuffer result; + try + { + result = ProcessTail(drained, context); + } + catch + { + // ProcessTail threw before taking ownership; dispose the drain we pulled (Dispose is + // idempotent, so a transforming node that already consumed it is unaffected). + drained.Dispose(); + throw; + } + // Pass-through ProcessTail hands back the same instance, which we must not dispose since we // return it; a transforming ProcessTail already consumed `drained` and returns a fresh one. if (!ReferenceEquals(result, drained)) From 92830a7aa8de2e5e5de501b3a70fc9122e1f1848 Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Wed, 24 Jun 2026 06:53:27 +0900 Subject: [PATCH 06/24] fix(engine): report worst-case animated latency on LimiterEffect too LimiterNode reports the animated worst-case lookahead, but the effect-level GetLatencySamples a host queries before graph construction still returned the CurrentValue snapshot, so it could under-reserve and reintroduce the tail loss. Mirror the node: an animated Lookahead reports MaxLookaheadMs. Addresses Codex review on PR #1998. Claude-Session: https://claude.ai/code/session_01PJ9eDL52jpiM1t5oDRCLM2 --- src/Beutl.Engine/Audio/Effects/LimiterEffect.cs | 6 +++++- .../Engine/Audio/AudioLatencyCompensationTests.cs | 6 ++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/Beutl.Engine/Audio/Effects/LimiterEffect.cs b/src/Beutl.Engine/Audio/Effects/LimiterEffect.cs index cf9cb610e6..72de015205 100644 --- a/src/Beutl.Engine/Audio/Effects/LimiterEffect.cs +++ b/src/Beutl.Engine/Audio/Effects/LimiterEffect.cs @@ -101,6 +101,10 @@ public override int GetLatencySamples(int sampleRate) if (!IsEnabled) return 0; - return ToLatencySamples(Lookahead.CurrentValue, sampleRate); + // 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/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs b/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs index 6c9cdcfe01..3c6387778c 100644 --- a/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs +++ b/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs @@ -307,6 +307,12 @@ public void LimiterNode_AnimatedLookahead_ReportsWorstCaseLatency() 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))); } // Source that honors the requested clip-local range: sample value keyed to the absolute clip-local From 7f61a94c9c461e2ddbb9f2fc2abdb4b6c35e4e34 Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Wed, 24 Jun 2026 13:05:09 +0900 Subject: [PATCH 07/24] fix(engine): remap nested ClipNode flush to clip-local time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A SoundGroup mixes child clips and then flushes them through the group's own clip in the group's time domain. A nested ClipNode inherited the base single-input Flush, which forwarded the parent's drain context unchanged, so the child's cached lookahead limiter saw a discontinuity, reset its delay line, and dropped the very tail being drained. Override ClipNode.Flush to rebuild the drain at clip-local Duration — where the clip's last Process slice ended — mirroring AppendFlushedTail. The parent's start is intentionally dropped, which is also why an intervening ShiftNode needs no flush override of its own. Document the remaining best-effort gaps (animated-lookahead drain, custom-leaf channel count, DelayNode latency budget) inline as known limitations / follow-ups rather than expanding the PR further. --- src/Beutl.Engine/Audio/Graph/AudioNode.cs | 6 ++- .../Audio/Graph/Nodes/ClipNode.cs | 20 ++++++++++ .../Audio/Graph/Nodes/DelayNode.cs | 6 +++ .../Audio/Graph/Nodes/LimiterNode.cs | 6 +++ .../Audio/AudioLatencyCompensationTests.cs | 39 +++++++++++++++++++ 5 files changed, 76 insertions(+), 1 deletion(-) diff --git a/src/Beutl.Engine/Audio/Graph/AudioNode.cs b/src/Beutl.Engine/Audio/Graph/AudioNode.cs index 61e2eae01d..a70ecef0e6 100644 --- a/src/Beutl.Engine/Audio/Graph/AudioNode.cs +++ b/src/Beutl.Engine/Audio/Graph/AudioNode.cs @@ -45,7 +45,11 @@ public void ClearInputs() 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. + /// 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 diff --git a/src/Beutl.Engine/Audio/Graph/Nodes/ClipNode.cs b/src/Beutl.Engine/Audio/Graph/Nodes/ClipNode.cs index cb54af03ed..56de97866e 100644 --- a/src/Beutl.Engine/Audio/Graph/Nodes/ClipNode.cs +++ b/src/Beutl.Engine/Audio/Graph/Nodes/ClipNode.cs @@ -72,6 +72,26 @@ public override AudioBuffer Process(AudioProcessContext context) } } + // 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. Rebuild the drain at clip-local Duration — where this clip's + // last Process slice ended — mirroring AppendFlushedTail; 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(Duration, context.TimeRange.Duration), + context.SampleRate, + context.AnimationSampler, + context.OriginalTimeRange); + + return Inputs[0].Flush(drainContext); + } + // 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. diff --git a/src/Beutl.Engine/Audio/Graph/Nodes/DelayNode.cs b/src/Beutl.Engine/Audio/Graph/Nodes/DelayNode.cs index fbedf08547..024e8c3688 100644 --- a/src/Beutl.Engine/Audio/Graph/Nodes/DelayNode.cs +++ b/src/Beutl.Engine/Audio/Graph/Nodes/DelayNode.cs @@ -28,6 +28,12 @@ public override AudioBuffer Process(AudioProcessContext context) return ProcessTail(Inputs[0].Process(context), context); } + // 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) { // Every path emits a fresh buffer (no pass-through), so dispose the consumed input. diff --git a/src/Beutl.Engine/Audio/Graph/Nodes/LimiterNode.cs b/src/Beutl.Engine/Audio/Graph/Nodes/LimiterNode.cs index adc17d73d5..38f603d8a2 100644 --- a/src/Beutl.Engine/Audio/Graph/Nodes/LimiterNode.cs +++ b/src/Beutl.Engine/Audio/Graph/Nodes/LimiterNode.cs @@ -73,6 +73,12 @@ public override AudioBuffer Process(AudioProcessContext context) // 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. + // + // Known limitation (animated lookahead): on the flush path ProcessAnimated samples Lookahead over + // the post-clip drain range, not the clip samples that were actually delayed. If automation holds a + // high lookahead through the clip end but keyframes back to 0 at Duration, the drain reads Read(0) + // from the just-written silence and skips the held samples, so the tail is still dropped even though + // GetLatencySamples reserved the worst case. Draining at the retaining lookahead is a follow-up. protected override AudioBuffer ProcessTail(AudioBuffer input, AudioProcessContext context) { if (input.SampleRate != context.SampleRate) diff --git a/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs b/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs index 3c6387778c..879303e195 100644 --- a/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs +++ b/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs @@ -267,6 +267,45 @@ public void MixerNode_Flush_MergesBranchTails() Assert.That(anyNonZero, Is.True, "The mixer flush merged branch A's drained tail instead of returning silence."); } + [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 Flush_FanInWithoutOverride_Throws() { From d1a20251d51e056143f0af1b283d72cd965b74dc Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Thu, 25 Jun 2026 12:45:20 +0900 Subject: [PATCH 08/24] fix(engine): flush a trimmed nested clip from its last processed time ClipNode.Flush drained from the clip's natural Duration, which is wrong when a parent (a SoundGroup window) trims the clip before its own end: the cached effects last processed up to the trim boundary, so draining from Duration skips past them, trips the limiter's discontinuity guard, and drops the tail held at the boundary. Track the clip-local end of the last processed window and drain from there instead. Document the remaining best-effort gaps inline as known limitations: MixerNode.Flush drains every branch unconditionally (a child that ended early on the chunk-alignment edge can emit a stale tail late), and SceneNode reports zero latency for a referenced scene (an inner limiter tail is dropped at a SceneSound cut). --- .../Audio/Graph/Nodes/ClipNode.cs | 15 ++++++-- .../Audio/Graph/Nodes/MixerNode.cs | 7 ++++ .../ProjectSystem/SceneSound.cs | 6 +++ .../Audio/AudioLatencyCompensationTests.cs | 38 +++++++++++++++++++ 4 files changed, 62 insertions(+), 4 deletions(-) diff --git a/src/Beutl.Engine/Audio/Graph/Nodes/ClipNode.cs b/src/Beutl.Engine/Audio/Graph/Nodes/ClipNode.cs index 56de97866e..a7b1851c53 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, @@ -76,15 +82,16 @@ public override AudioBuffer Process(AudioProcessContext context) // 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. Rebuild the drain at clip-local Duration — where this clip's - // last Process slice ended — mirroring AppendFlushedTail; the parent's start is intentionally - // dropped, which is also why an intervening ShiftNode needs no flush override of its own. + // 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(Duration, context.TimeRange.Duration), + new TimeRange(_lastProcessedLocalEnd ?? Duration, context.TimeRange.Duration), context.SampleRate, context.AnimationSampler, context.OriginalTimeRange); diff --git a/src/Beutl.Engine/Audio/Graph/Nodes/MixerNode.cs b/src/Beutl.Engine/Audio/Graph/Nodes/MixerNode.cs index fa9bc254a3..ee8d48b055 100644 --- a/src/Beutl.Engine/Audio/Graph/Nodes/MixerNode.cs +++ b/src/Beutl.Engine/Audio/Graph/Nodes/MixerNode.cs @@ -18,6 +18,13 @@ public override AudioBuffer Process(AudioProcessContext context) // Fan-in flush: drain every branch and mix the held tails with the same gain fold as Process, so a // lookahead tail in any branch is recovered (the base Flush's single-input path cannot reach here). + // + // Known limitation: branches are drained unconditionally. A branch whose clip ended before the + // group's terminal slice — and whose own terminal block landed exactly on its clip boundary (the + // chunk-alignment edge where ClipNode could not self-recover) — still holds a stale tail that this + // emits into the group pad seconds late. Skipping only the branches live through the terminal slice + // needs per-branch clip-liveness the mixer does not track today; recovering at the child's own end is + // the chunk-alignment follow-up. public override AudioBuffer Flush(AudioProcessContext context) { ArgumentNullException.ThrowIfNull(context); 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/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs b/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs index 879303e195..d9c41d1848 100644 --- a/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs +++ b/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs @@ -306,6 +306,44 @@ public void NestedClipNode_Flush_RemapsToClipLocalTime_RecoversTail() + "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 Flush_FanInWithoutOverride_Throws() { From 514d013518f67e525c29f8a7e00aeafe1021cebd Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Thu, 25 Jun 2026 15:13:54 +0900 Subject: [PATCH 09/24] fix(engine): continue a nested clip drain after a partial tail append When a nested clip's terminal window had trailing pad smaller than the reported latency, AppendFlushedTail drained only part of the held tail and advanced the upstream chain past Duration, but _lastProcessedLocalEnd stayed at Duration. A later parent flush of the same child then restarted the drain at the stale time, stepping the cached effects backward, which tripped the discontinuity guard, reset them, and dropped the rest of the tail. Record the advanced drain position after the append so Flush continues from it. Document the ResampleNode flush gap (no rate-mirroring Flush override) as a known limitation; it only bites a custom graph that resamples after a latency-bearing node, since the built-in Sound chain resamples first. --- .../Audio/Graph/Nodes/ClipNode.cs | 6 +++ .../Audio/Graph/Nodes/ResampleNode.cs | 7 ++++ .../Audio/AudioLatencyCompensationTests.cs | 37 +++++++++++++++++++ 3 files changed, 50 insertions(+) diff --git a/src/Beutl.Engine/Audio/Graph/Nodes/ClipNode.cs b/src/Beutl.Engine/Audio/Graph/Nodes/ClipNode.cs index a7b1851c53..d3fa910193 100644 --- a/src/Beutl.Engine/Audio/Graph/Nodes/ClipNode.cs +++ b/src/Beutl.Engine/Audio/Graph/Nodes/ClipNode.cs @@ -131,5 +131,11 @@ private void AppendFlushedTail(AudioProcessContext context, AudioBuffer newBuffe { 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/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/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs b/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs index d9c41d1848..9bfc2862bf 100644 --- a/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs +++ b/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs @@ -344,6 +344,43 @@ public void NestedClipNode_Flush_DrainsFromLastProcessedLocalTime_WhenParentTrim + "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 Flush_FanInWithoutOverride_Throws() { From 4e3709e13477a3c540143903b73eaef0a3c9c7f9 Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Thu, 25 Jun 2026 16:46:15 +0900 Subject: [PATCH 10/24] fix(engine): advance clip-local drain state on nested ClipNode Flush ClipNode.Flush drained the input chain but never recorded the new clip-local end, so a parent flushing the same nested clip across multiple blocks (its tail capacity below the child's latency) replayed the drain from the old local end, tripping the cached effects' discontinuity guard and emitting the remaining tail as silence. Record drainContext.TimeRange.End after the upstream flush, mirroring AppendFlushedTail. Also harden two ClipNode tail tests that passed trivially: - TerminalWindow_AppendsRecoveredTail now extends the window past the clip end so AppendFlushedTail actually drains, asserting on the recovered [clipSamples, clipSamples+L) region instead of the main slice (which held real sine regardless of recovery). - ZeroLookahead_IsNoOp now uses a reference window that stops short of the clip end, giving the overlap comparison discriminating power. --- .../Audio/Graph/Nodes/ClipNode.cs | 8 ++++++- .../Audio/AudioLatencyCompensationTests.cs | 21 ++++++++++++------- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/src/Beutl.Engine/Audio/Graph/Nodes/ClipNode.cs b/src/Beutl.Engine/Audio/Graph/Nodes/ClipNode.cs index d3fa910193..7d861e5113 100644 --- a/src/Beutl.Engine/Audio/Graph/Nodes/ClipNode.cs +++ b/src/Beutl.Engine/Audio/Graph/Nodes/ClipNode.cs @@ -96,7 +96,13 @@ public override AudioBuffer Flush(AudioProcessContext context) context.AnimationSampler, context.OriginalTimeRange); - return Inputs[0].Flush(drainContext); + 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 diff --git a/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs b/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs index 9bfc2862bf..243fea5abe 100644 --- a/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs +++ b/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs @@ -166,25 +166,28 @@ public void ClipNode_TerminalWindow_AppendsRecoveredTail() using var clip = new ClipNode { Start = TimeSpan.Zero, Duration = clipDuration }; clip.AddInput(limiter); - // A window that exactly covers the whole clip: it reaches the clip's true end, so ClipNode - // drains the limiter tail into the trailing samples. - using var output = clip.Process(Context(TimeSpan.Zero, clipSamples)); + // 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 - L; i < clipSamples; i++) + for (int i = clipSamples; i < clipSamples + L; i++) { if (MathF.Abs(data[i]) > 1e-5f) { tailNonZero = true; break; } } Assert.That(tailNonZero, Is.True, - "The clip's final L samples, normally lost in the delay line, are recovered by the tail flush."); + "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. @@ -196,12 +199,14 @@ public void ClipNode_ZeroLookahead_IsNoOp() var sourceB = new RangeSineNode(SampleRate); using var clipB = new ClipNode { Start = TimeSpan.Zero, Duration = clipDuration }; clipB.AddInput(sourceB); - // A mid-clip window (does not reach the end) never drains; compare its overlapping region. - using var reference = clipB.Process(Context(TimeSpan.Zero, clipSamples)); + // 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; i++) + 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}."); } From 18e7e14cc78e3ccfb8d70608d3e2eb25fa0bed47 Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Thu, 25 Jun 2026 19:36:37 +0900 Subject: [PATCH 11/24] fix(engine): drain limiter tail at retained lookahead under animation On the Flush path the limiter ran the drained tail through ProcessAnimated, which re-samples Lookahead over the post-clip drain range. When automation holds a high lookahead through the clip end but keyframes back to 0 at Duration, the drain read delay offset 0 (the flush silence) and dropped the held tail, even though GetLatencySamples had reserved the worst-case room. Thread a `draining` flag through AudioNode.ProcessTail (Process passes false, Flush passes true). LimiterNode now retains the coefficients of the clip's terminal sample and, while draining an animated chain, reads the delay line at that frozen lookahead instead of the decayed automation value, so the held samples are recovered at the offset they were buffered. Static and non-draining paths are unchanged. The other ProcessTail overrides (Gain/EQ/Compressor/Delay) ignore the flag. --- src/Beutl.Engine/Audio/Graph/AudioNode.cs | 17 ++-- .../Audio/Graph/Nodes/DelayNode.cs | 4 +- .../Audio/Graph/Nodes/DynamicsNode.cs | 4 +- .../Audio/Graph/Nodes/EqualizerNode.cs | 4 +- .../Audio/Graph/Nodes/GainNode.cs | 4 +- .../Audio/Graph/Nodes/LimiterNode.cs | 78 ++++++++++++++++--- .../Audio/AudioLatencyCompensationTests.cs | 48 ++++++++++++ 7 files changed, 134 insertions(+), 25 deletions(-) diff --git a/src/Beutl.Engine/Audio/Graph/AudioNode.cs b/src/Beutl.Engine/Audio/Graph/AudioNode.cs index a70ecef0e6..efccc97686 100644 --- a/src/Beutl.Engine/Audio/Graph/AudioNode.cs +++ b/src/Beutl.Engine/Audio/Graph/AudioNode.cs @@ -33,12 +33,17 @@ public void ClearInputs() /// /// Applies this node's own processing to an already-produced buffer /// instead of pulling [0] itself. feeds it real upstream - /// audio; feeds it the drained tail, so a transforming node processes the tail - /// the same way it processes the body. The default is pass-through (returns - /// unchanged), keeping the zero-processing path byte-identical. A node that returns a fresh buffer - /// takes ownership of and disposes it, exactly as its Process already does. + /// 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 + /// returns a fresh buffer takes ownership of and disposes it, exactly as its + /// Process already does. /// - protected virtual AudioBuffer ProcessTail(AudioBuffer input, AudioProcessContext context) => input; + 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. @@ -80,7 +85,7 @@ public virtual AudioBuffer Flush(AudioProcessContext context) AudioBuffer result; try { - result = ProcessTail(drained, context); + result = ProcessTail(drained, context, draining: true); } catch { diff --git a/src/Beutl.Engine/Audio/Graph/Nodes/DelayNode.cs b/src/Beutl.Engine/Audio/Graph/Nodes/DelayNode.cs index 024e8c3688..010f494c78 100644 --- a/src/Beutl.Engine/Audio/Graph/Nodes/DelayNode.cs +++ b/src/Beutl.Engine/Audio/Graph/Nodes/DelayNode.cs @@ -25,7 +25,7 @@ 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); + return ProcessTail(Inputs[0].Process(context), context, draining: false); } // Deliberately keeps the base GetLatencySamples of 0: an echo/delay is an intentional, audible @@ -34,7 +34,7 @@ public override AudioBuffer Process(AudioProcessContext context) // 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) + 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 owned = input; diff --git a/src/Beutl.Engine/Audio/Graph/Nodes/DynamicsNode.cs b/src/Beutl.Engine/Audio/Graph/Nodes/DynamicsNode.cs index 5471829798..50a793d11e 100644 --- a/src/Beutl.Engine/Audio/Graph/Nodes/DynamicsNode.cs +++ b/src/Beutl.Engine/Audio/Graph/Nodes/DynamicsNode.cs @@ -50,10 +50,10 @@ public override AudioBuffer Process(AudioProcessContext context) throw new InvalidOperationException( $"{DiagnosticName} node requires exactly one input but got {Inputs.Count}."); - return ProcessTail(Inputs[0].Process(context), context); + return ProcessTail(Inputs[0].Process(context), context, draining: false); } - protected override AudioBuffer ProcessTail(AudioBuffer input, AudioProcessContext context) + 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 c78a698139..c78f1bfb12 100644 --- a/src/Beutl.Engine/Audio/Graph/Nodes/EqualizerNode.cs +++ b/src/Beutl.Engine/Audio/Graph/Nodes/EqualizerNode.cs @@ -24,10 +24,10 @@ public override AudioBuffer Process(AudioProcessContext context) if (Inputs.Count != 1) throw new InvalidOperationException("Equalizer node requires exactly one input."); - return ProcessTail(Inputs[0].Process(context), context); + return ProcessTail(Inputs[0].Process(context), context, draining: false); } - protected override AudioBuffer ProcessTail(AudioBuffer input, AudioProcessContext context) + 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 edfcb829ff..4035d723f0 100644 --- a/src/Beutl.Engine/Audio/Graph/Nodes/GainNode.cs +++ b/src/Beutl.Engine/Audio/Graph/Nodes/GainNode.cs @@ -11,10 +11,10 @@ public override AudioBuffer Process(AudioProcessContext context) if (Inputs.Count != 1) throw new InvalidOperationException("Gain node requires exactly one input."); - return ProcessTail(Inputs[0].Process(context), context); + return ProcessTail(Inputs[0].Process(context), context, draining: false); } - protected override AudioBuffer ProcessTail(AudioBuffer input, AudioProcessContext context) + protected override AudioBuffer ProcessTail(AudioBuffer input, AudioProcessContext context, bool draining) { if (Gain.Animation is null) { diff --git a/src/Beutl.Engine/Audio/Graph/Nodes/LimiterNode.cs b/src/Beutl.Engine/Audio/Graph/Nodes/LimiterNode.cs index 38f603d8a2..66b5a79945 100644 --- a/src/Beutl.Engine/Audio/Graph/Nodes/LimiterNode.cs +++ b/src/Beutl.Engine/Audio/Graph/Nodes/LimiterNode.cs @@ -36,6 +36,12 @@ 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 reuses these instead of re-sampling automation over the post-clip range, so the held + // tail is read at the lookahead it was buffered at. 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. @@ -67,19 +73,13 @@ public override AudioBuffer Process(AudioProcessContext context) using var input = Inputs[0].Process(context) ?? throw new InvalidOperationException("LimiterNode: upstream Process returned null."); - return ProcessTail(input, context); + return ProcessTail(input, 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. - // - // Known limitation (animated lookahead): on the flush path ProcessAnimated samples Lookahead over - // the post-clip drain range, not the clip samples that were actually delayed. If automation holds a - // high lookahead through the clip end but keyframes back to 0 at Duration, the drain reads Read(0) - // from the just-written silence and skips the held samples, so the tail is still dropped even though - // GetLatencySamples reserved the worst case. Draining at the retaining lookahead is a follow-up. - protected override AudioBuffer ProcessTail(AudioBuffer input, AudioProcessContext context) + protected override AudioBuffer ProcessTail(AudioBuffer input, AudioProcessContext context, bool draining) { if (input.SampleRate != context.SampleRate) throw new InvalidOperationException( @@ -130,9 +130,22 @@ protected override AudioBuffer ProcessTail(AudioBuffer input, AudioProcessContex || Lookahead.Animation != null || MakeupGain.Animation != null; - var output = hasAnimation - ? ProcessAnimated(input, context) - : ProcessStatic(input, context); + AudioBuffer output; + if (draining && hasAnimation && _hasLastDerived) + { + // Re-sampling animated parameters 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 (a keyframe back to 0 at Duration would read Read(0), the + // flush silence, and drop the tail). Drain at the coefficients retained from the terminal + // sample so the held samples are read out at the offset they were stored. + output = DrainFrozen(input, context, _lastDerived); + } + 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 @@ -219,6 +232,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; @@ -277,6 +291,8 @@ private DerivedCoefficients Derive(float thresholdDbRaw, float releaseMsRaw, flo 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 @@ -351,6 +367,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). @@ -373,6 +391,41 @@ private AudioBuffer ProcessAnimated(AudioBuffer input, AudioProcessContext conte } } + // Drains the held tail at fixed coefficients (the clip's terminal sample), bypassing automation + // re-sampling. Mirrors the animated path's per-sample emit but with constant c, so the delay line + // is read at the lookahead the tail was buffered at. Used only on the Flush path with animation. + private AudioBuffer DrainFrozen(AudioBuffer input, AudioProcessContext context, DerivedCoefficients c) + { + var output = new AudioBuffer(input.SampleRate, input.ChannelCount, input.SampleCount); + try + { + // The deque tracks no fixed lookahead across this 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(); + + for (int idx = 0; idx < sampleCount; idx++) + { + _ = IngestSample(inRaw, sampleCount, channelCount, idx); + float windowPeak = ScanWindowPeak(c.LookaheadSamples); + EmitSample(outRaw, sampleCount, channelCount, idx, windowPeak, c.ThresholdLin, c.MakeupLin, c.LookaheadSamples, c.ReleaseCoef); + } + + 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), @@ -566,6 +619,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/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs b/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs index 243fea5abe..ec8c06494b 100644 --- a/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs +++ b/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs @@ -434,6 +434,54 @@ public void LimiterNode_AnimatedLookahead_ReportsWorstCaseLatency() 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."); + } + } + // 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). From c869759a655a8b2cb773a56588e2646fc56373b7 Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Thu, 25 Jun 2026 19:44:40 +0900 Subject: [PATCH 12/24] fix(engine): skip dead branches when flushing a SoundGroup mixer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MixerNode.Flush drained every input branch unconditionally. A SoundGroup child that ended before the group's terminal slice — and whose own terminal block landed on its clip boundary (the chunk-alignment edge where ClipNode cannot self-recover) — still holds a stale tail, which the mixer then mixed into the group pad seconds late. Add per-branch clip-liveness: MixerNode.BranchEndTimes carries each branch's group-local clip end (parallel to Inputs; empty keeps the old drain-all behavior). On flush, a branch whose clip ended before the drain block is skipped; if every branch is dead the mixer flushes silence. SoundGroup populates BranchEndTimes in connect order as it wires children to the mixer. --- .../Audio/Graph/Nodes/MixerNode.cs | 75 +++++++++++++++---- src/Beutl.Engine/Audio/SoundGroup.cs | 7 ++ .../Audio/AudioLatencyCompensationTests.cs | 36 +++++++++ 3 files changed, 104 insertions(+), 14 deletions(-) diff --git a/src/Beutl.Engine/Audio/Graph/Nodes/MixerNode.cs b/src/Beutl.Engine/Audio/Graph/Nodes/MixerNode.cs index ee8d48b055..4b5159526b 100644 --- a/src/Beutl.Engine/Audio/Graph/Nodes/MixerNode.cs +++ b/src/Beutl.Engine/Audio/Graph/Nodes/MixerNode.cs @@ -5,7 +5,10 @@ namespace Beutl.Audio.Graph.Nodes; public sealed class MixerNode : AudioNode { + private const long BranchLivenessToleranceTicks = TimeSpan.TicksPerMillisecond; + private float[] _gains = Array.Empty(); + private TimeSpan[] _branchEndTimes = Array.Empty(); public float[] Gains { @@ -13,6 +16,15 @@ public float[] Gains set => _gains = value ?? Array.Empty(); } + // Group-local end time of each input branch, parallel to Inputs (empty = every branch live, so all + // drain — the back-compat default). A branch whose clip ended before the flush block is dead: its + // tail was recovered at its own clip end, so Flush must skip it instead of re-emitting it late. + public TimeSpan[] BranchEndTimes + { + get => _branchEndTimes; + set => _branchEndTimes = value ?? Array.Empty(); + } + public override AudioBuffer Process(AudioProcessContext context) => Mix(context, drain: false); @@ -40,25 +52,46 @@ 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++) { + if (drain && IsBranchDead(i, context)) + continue; + buffers[i] = drain ? Inputs[i].Flush(context) : Inputs[i].Process(context); } - // Validate all buffers have the same format - var firstBuffer = buffers[0]; - for (int i = 1; i < buffers.Length; i++) + // The format reference is the first live branch; every branch dead means nothing to drain. + AudioBuffer? firstBuffer = null; + foreach (var buffer in buffers) { - 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}."); + if (buffer != null) + { + firstBuffer = buffer; + break; + } + } + + if (firstBuffer == null) + return CreateSilentFlush(context); + + // Validate live buffers have the same format + for (int i = 0; i < buffers.Length; i++) + { + 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 @@ -73,11 +106,15 @@ private AudioBuffer Mix(AudioProcessContext context, bool drain) // 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++) @@ -99,12 +136,22 @@ private AudioBuffer Mix(AudioProcessContext context, bool drain) } 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 (index >= _branchEndTimes.Length) + 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. + return _branchEndTimes[index].Ticks + BranchLivenessToleranceTicks < context.TimeRange.Start.Ticks; + } } diff --git a/src/Beutl.Engine/Audio/SoundGroup.cs b/src/Beutl.Engine/Audio/SoundGroup.cs index bab43a12af..9a53fab042 100644 --- a/src/Beutl.Engine/Audio/SoundGroup.cs +++ b/src/Beutl.Engine/Audio/SoundGroup.cs @@ -78,6 +78,10 @@ public override void Compose(AudioContext context, Sound.Resource resource) // SoundGroupの処理を加える var mixerNode = context.CreateMixerNode(); + // Group-local clip end of each mixer branch, in connect order. A child that ends before the + // group lets the mixer skip it on flush so its already-recovered tail is not re-emitted late. + var branchEndTimes = new List(); + foreach (var child in r.Children) { var original = child.GetOriginal(); @@ -95,9 +99,12 @@ public override void Compose(AudioContext context, Sound.Resource resource) var shiftNode = context.CreateShiftNode(TimeRange.Start); context.Connect(outputNode, shiftNode); context.Connect(shiftNode, mixerNode); + branchEndTimes.Add(original.TimeRange.End - TimeRange.Start); } } + mixerNode.BranchEndTimes = [.. branchEndTimes]; + AudioNode currentNode = mixerNode; // SoundGroup全体のGainを適用 diff --git a/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs b/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs index ec8c06494b..ba0eb5c5f2 100644 --- a/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs +++ b/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs @@ -272,6 +272,42 @@ public void MixerNode_Flush_MergesBranchTails() 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.BranchEndTimes = [earlyEnd, 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 NestedClipNode_Flush_RemapsToClipLocalTime_RecoversTail() { From 701faf4832bb887d9bc3725b48c43334e1333e25 Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Thu, 25 Jun 2026 19:59:58 +0900 Subject: [PATCH 13/24] fix(engine): flush a sound's tail when it ends on a compose boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a sound ended exactly on a compose-window boundary, its ClipNode's terminal window was full (capacity 0), so AppendFlushedTail could not recover the limiter tail, and the next window excluded the sound entirely (it dropped out of frame.Objects), so the held tail was lost. The Composer now records the entries active each window. When a sound is active in the previous window but not the current one — and the windows are contiguous (not a seek) — it flushes that sound's cached output graph and mixes the drained tail into the start of the current window, where it belongs ([windowStart, windowStart + latency)). The drain is a no-op for sounds that already self-recovered mid-window, so it never double-emits. Adds a file-free LimiterTailSound test harness and an end-to-end test that composes a limiter-bearing sound across the boundary. --- src/Beutl.Engine/Audio/Composing/Composer.cs | 57 ++++++++++++++++- .../Audio/AudioLatencyCompensationTests.cs | 46 +++++++++++++- .../Engine/Audio/LimiterTailSound.cs | 63 +++++++++++++++++++ 3 files changed, 164 insertions(+), 2 deletions(-) create mode 100644 tests/Beutl.UnitTests/Engine/Audio/LimiterTailSound.cs diff --git a/src/Beutl.Engine/Audio/Composing/Composer.cs b/src/Beutl.Engine/Audio/Composing/Composer.cs index d37c73abb2..2406ee5c94 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 active last + // window but not this one ended at the boundary; its graph still holds the 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). + private readonly List _previousEntry = new(); + private TimeRange? _previousRange; + private sealed class AudioNodeEntry : IDisposable { public List Nodes { get; set; } = new(); @@ -78,7 +85,14 @@ public void Dispose() } // Build final audio graph - return BuildFinalOutput(timeRange); + var result = BuildFinalOutput(timeRange); + + // 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 { @@ -109,6 +123,9 @@ public void Dispose() } } + // Recover the latency tail of any sound that ended on the previous window boundary. + AppendEndedSoundTails(range, buffers); + // Mix all buffers mixedBuffer = MixBuffers(buffers); @@ -139,6 +156,39 @@ 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, 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.OutputNodes is not { } outputNodes) + 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 AudioBuffer? MixBuffers(List buffers) { if (buffers.Count == 0) @@ -200,6 +250,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; } /// diff --git a/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs b/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs index ba0eb5c5f2..dc9de5fe24 100644 --- a/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs +++ b/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs @@ -1,9 +1,13 @@ -using Beutl.Animation; +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.Graph; using Beutl.Audio.Graph.Nodes; +using Beutl.Composition; using Beutl.Engine; using Beutl.Logging; using Beutl.Media; @@ -518,6 +522,46 @@ public void Flush_AnimatedLookaheadDroppingToZero_StillRecoversHeldTail() } } + [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 frame1 = new CompositionFrame(ImmutableArray.Create(resource), window1, default); + using var buffer1 = composer.Compose(window1, frame1); + + var window2 = new TimeRange(oneSecond, oneSecond); + var frame2 = new CompositionFrame(ImmutableArray.Empty, window2, default); + 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."); + } + // 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). 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; + } +} From bffc3ecc0545a73af1b866467168a310e31b4f45 Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Thu, 25 Jun 2026 21:01:48 +0900 Subject: [PATCH 14/24] fix(engine): gate AudioEffectGroup latency on its own IsEnabled A disabled AudioEffectGroup contributes no nodes to the graph (Sound.Compose skips a disabled effect's CreateNode), yet GetLatencySamples still summed its enabled children, so a pre-graph latency query disagreed with the built graph. Return 0 when the group itself is disabled, matching LimiterEffect. Also harden the surrounding latency-reporting surface from PR review: - LimiterNode.GetLatencySamples guards sampleRate at the override boundary so a future early-return cannot bypass the contract. - AudioEffect.GetLatencySamples documents that an override must agree with its node's GetLatencySamples and return 0 when IsEnabled is false. - Add Flush-path tests for DelayNode/CompressorNode/EqualizerNode covering the Process->ProcessTail buffer-ownership transfer on the drain. --- src/Beutl.Engine/Audio/Effects/AudioEffect.cs | 6 ++ .../Audio/Effects/AudioEffectGroup.cs | 7 ++- .../Audio/Graph/Nodes/LimiterNode.cs | 6 +- .../Audio/AudioLatencyCompensationTests.cs | 63 +++++++++++++++++++ .../Engine/Audio/AudioLatencyTests.cs | 10 ++- 5 files changed, 82 insertions(+), 10 deletions(-) diff --git a/src/Beutl.Engine/Audio/Effects/AudioEffect.cs b/src/Beutl.Engine/Audio/Effects/AudioEffect.cs index ad7b3e5eb5..29ee2f541c 100644 --- a/src/Beutl.Engine/Audio/Effects/AudioEffect.cs +++ b/src/Beutl.Engine/Audio/Effects/AudioEffect.cs @@ -16,6 +16,12 @@ public abstract partial class AudioEffect : EngineObject /// 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. + /// /// is not positive. public virtual int GetLatencySamples(int sampleRate) { diff --git a/src/Beutl.Engine/Audio/Effects/AudioEffectGroup.cs b/src/Beutl.Engine/Audio/Effects/AudioEffectGroup.cs index 104234fc54..863df1b118 100644 --- a/src/Beutl.Engine/Audio/Effects/AudioEffectGroup.cs +++ b/src/Beutl.Engine/Audio/Effects/AudioEffectGroup.cs @@ -21,12 +21,13 @@ public override AudioNode CreateNode(AudioContext context, AudioNode inputNode) .Aggregate(inputNode, (current, item) => item.CreateNode(context, current)); } - // Enabled children run as a serial cascade (see CreateNode), so their latencies add. Mirrors - // CreateNode: filters on each child's IsEnabled but does not gate on the group's own IsEnabled — - // callers decide whether to skip a disabled group (Sound only builds the chain when enabled). + // 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; return Children.Where(item => item.IsEnabled) .Sum(item => item.GetLatencySamples(sampleRate)); diff --git a/src/Beutl.Engine/Audio/Graph/Nodes/LimiterNode.cs b/src/Beutl.Engine/Audio/Graph/Nodes/LimiterNode.cs index 66b5a79945..9b7ffc6ac5 100644 --- a/src/Beutl.Engine/Audio/Graph/Nodes/LimiterNode.cs +++ b/src/Beutl.Engine/Audio/Graph/Nodes/LimiterNode.cs @@ -165,9 +165,13 @@ protected override AudioBuffer ProcessTail(AudioBuffer input, AudioProcessContex /// room even when the automation peaks near the clip end. /// public override int GetLatencySamples(int sampleRate) - => Lookahead.Animation != null + { + // 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) { diff --git a/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs b/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs index dc9de5fe24..8e0d94d891 100644 --- a/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs +++ b/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs @@ -5,6 +5,7 @@ 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; @@ -562,6 +563,68 @@ public void Composer_FlushesSoundEndingExactlyAtTheWindowBoundary() "A sound ending exactly at the window boundary must have its limiter tail flushed into the next window's start."); } + // 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). diff --git a/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyTests.cs b/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyTests.cs index 815e38327d..9618028817 100644 --- a/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyTests.cs +++ b/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyTests.cs @@ -224,17 +224,15 @@ public void AudioEffectGroup_GetLatencySamples_ExcludesDisabledChildren() } [Test] - public void AudioEffectGroup_GetLatencySamples_IgnoresGroupOwnIsEnabled() + public void AudioEffectGroup_GetLatencySamples_DisabledGroupReportsZero() { - // The group's own IsEnabled does not gate the report (it mirrors CreateNode, which only filters - // children); the caller decides whether to skip a disabled group, so a disabled group still - // sums its enabled children. + // 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)); - int expected = ExpectedLookaheadSamples(5f, SampleRate) + ExpectedLookaheadSamples(10f, SampleRate); - Assert.That(group.GetLatencySamples(SampleRate), Is.EqualTo(expected)); + Assert.That(group.GetLatencySamples(SampleRate), Is.EqualTo(0)); } [Test] From c02956852110f1040b0a4416555f1fbcaa5398de Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Fri, 3 Jul 2026 17:06:27 +0900 Subject: [PATCH 15/24] test(engine): pin Composer flush-suppression guards; document SpeedNode flush gap Adds two Composer-level tests closing the coverage gap on AppendEndedSoundTails' suppression branches: a non-contiguous (seek) window must not inject the previous clip's stale limiter tail (IsContiguous early return), and InvalidateCache must drop the recorded previous window so a subsequent contiguous window flushes nothing. Both mirror Composer_FlushesSoundEndingExactlyAtTheWindowBoundary and assert silence where that test asserts a recovered tail. Also documents SpeedNode's missing Flush override (same class of limitation as ResampleNode's, already documented there): a latency-bearing node placed upstream of a SpeedNode would drop its tail on flush. Comment-only; no behavior change. --- .../Audio/Graph/Nodes/SpeedNode.cs | 6 ++ .../Audio/AudioLatencyCompensationTests.cs | 77 +++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/src/Beutl.Engine/Audio/Graph/Nodes/SpeedNode.cs b/src/Beutl.Engine/Audio/Graph/Nodes/SpeedNode.cs index 0282d72d24..bae2e4d99b 100644 --- a/src/Beutl.Engine/Audio/Graph/Nodes/SpeedNode.cs +++ b/src/Beutl.Engine/Audio/Graph/Nodes/SpeedNode.cs @@ -22,6 +22,12 @@ public SpeedNode() public IProperty? Speed { get; set; } + // Known limitation (same class as ResampleNode's): no Flush override. Process streams its input + // through a resampler at a derived source position/rate; the inherited flush instead forwards the + // output context straight upstream, so a latency-bearing node placed UPSTREAM of this SpeedNode + // would see a discontinuity on flush, re-anchor, and drop its tail. The built-in Sound chain puts + // SpeedNode before the effects, so a limiter is always downstream — this only bites a custom graph + // that time-stretches after a latency-bearing node; a re-anchoring Flush override is the follow-up. public override AudioBuffer Process(AudioProcessContext context) { if (Inputs.Count != 1) diff --git a/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs b/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs index 8e0d94d891..b9e47fd21c 100644 --- a/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs +++ b/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs @@ -563,6 +563,83 @@ public void Composer_FlushesSoundEndingExactlyAtTheWindowBoundary() "A sound ending exactly at the window boundary must have its limiter tail flushed into the next window's start."); } + // 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 frame1 = new CompositionFrame(ImmutableArray.Create(resource), window1, default); + 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); + 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 frame1 = new CompositionFrame(ImmutableArray.Create(resource), window1, default); + 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); + 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] From 61ae3ef3d747cce3fed57c880ce9b8ca4fdc8826 Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Mon, 27 Jul 2026 01:55:19 +0900 Subject: [PATCH 16/24] fix: address audio latency review feedback --- src/Beutl.Engine/Audio/Composing/Composer.cs | 15 +- src/Beutl.Engine/Audio/Graph/AudioContext.cs | 68 ++-- src/Beutl.Engine/Audio/Graph/AudioNode.cs | 42 ++- .../Audio/Graph/Nodes/LimiterNode.cs | 89 +++-- .../Audio/Graph/Nodes/MixerNode.cs | 114 ++++++- src/Beutl.Engine/Audio/SoundGroup.cs | 10 +- .../Audio/AudioLatencyCompensationTests.cs | 319 +++++++++++++++++- 7 files changed, 583 insertions(+), 74 deletions(-) diff --git a/src/Beutl.Engine/Audio/Composing/Composer.cs b/src/Beutl.Engine/Audio/Composing/Composer.cs index 2406ee5c94..3a0ec2442e 100644 --- a/src/Beutl.Engine/Audio/Composing/Composer.cs +++ b/src/Beutl.Engine/Audio/Composing/Composer.cs @@ -12,10 +12,10 @@ 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 active last - // window but not this one ended at the boundary; its graph still holds the 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). + // 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; @@ -26,6 +26,7 @@ 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 void Dispose() { @@ -174,6 +175,8 @@ private void AppendEndedSoundTails(TimeRange range, List buffers) continue; if (entry.OutputNodes is not { } outputNodes) continue; + if (!IsSameTimestamp(entry.SoundRange.End, previous.End)) + continue; foreach (var outputNode in outputNodes) { @@ -189,6 +192,9 @@ private void AppendEndedSoundTails(TimeRange range, List buffers) 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) @@ -300,6 +306,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/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 efccc97686..d0836e2030 100644 --- a/src/Beutl.Engine/Audio/Graph/AudioNode.cs +++ b/src/Beutl.Engine/Audio/Graph/AudioNode.cs @@ -11,21 +11,59 @@ public void AddInput(AudioNode input) { ArgumentNullException.ThrowIfNull(input); - if (_inputs.Contains(input)) + if (IndexOfInput(input) >= 0) return; _inputs.Add(input); + OnInputAdded(input, _inputs.Count - 1); } public void RemoveInput(AudioNode input) { ArgumentNullException.ThrowIfNull(input); - _inputs.Remove(input); + + int index = IndexOfInput(input); + if (index < 0) + return; + + _inputs.RemoveAt(index); + OnInputRemoved(input, index); } public void ClearInputs() { _inputs.Clear(); + OnInputsCleared(); + } + + /// Called after an input is appended, allowing derived nodes to keep connection metadata + /// aligned with . + protected virtual void OnInputAdded(AudioNode input, int index) + { + } + + /// Called after an input is removed, with its former position in + /// . + 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 . + 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); diff --git a/src/Beutl.Engine/Audio/Graph/Nodes/LimiterNode.cs b/src/Beutl.Engine/Audio/Graph/Nodes/LimiterNode.cs index 9b7ffc6ac5..3f2afd0feb 100644 --- a/src/Beutl.Engine/Audio/Graph/Nodes/LimiterNode.cs +++ b/src/Beutl.Engine/Audio/Graph/Nodes/LimiterNode.cs @@ -37,8 +37,9 @@ public sealed class LimiterNode : AudioNode private long _globalPos; // The coefficients the last real-audio sample was processed with. A drain (Flush) following the - // clip end reuses these instead of re-sampling automation over the post-clip range, so the held - // tail is read at the lookahead it was buffered at. Invalid after Reset/format change (cold state). + // 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; @@ -131,14 +132,13 @@ protected override AudioBuffer ProcessTail(AudioBuffer input, AudioProcessContex || MakeupGain.Animation != null; AudioBuffer output; - if (draining && hasAnimation && _hasLastDerived) + if (draining && Lookahead.Animation != null && _hasLastDerived) { - // Re-sampling animated parameters 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 (a keyframe back to 0 at Duration would read Read(0), the - // flush silence, and drop the tail). Drain at the coefficients retained from the terminal - // sample so the held samples are read out at the offset they were stored. - output = DrainFrozen(input, context, _lastDerived); + // 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 { @@ -276,19 +276,31 @@ 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))); } @@ -395,16 +407,24 @@ private AudioBuffer ProcessAnimated(AudioBuffer input, AudioProcessContext conte } } - // Drains the held tail at fixed coefficients (the clip's terminal sample), bypassing automation - // re-sampling. Mirrors the animated path's per-sample emit but with constant c, so the delay line - // is read at the lookahead the tail was buffered at. Used only on the Flush path with animation. - private AudioBuffer DrainFrozen(AudioBuffer input, AudioProcessContext context, DerivedCoefficients c) + // 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 { - // The deque tracks no fixed lookahead across this block (it reads via ScanWindowPeak like - // the animated path), so invalidate it for a later static chunk to rebuild. + 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; @@ -412,11 +432,38 @@ private AudioBuffer DrainFrozen(AudioBuffer input, AudioProcessContext context, ReadOnlySpan inRaw = input.GetRawSpan(); Span outRaw = output.GetRawSpan(); - for (int idx = 0; idx < sampleCount; idx++) + int processed = 0; + while (processed < sampleCount) { - _ = IngestSample(inRaw, sampleCount, channelCount, idx); - float windowPeak = ScanWindowPeak(c.LookaheadSamples); - EmitSample(outRaw, sampleCount, channelCount, idx, windowPeak, c.ThresholdLin, c.MakeupLin, c.LookaheadSamples, c.ReleaseCoef); + 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; diff --git a/src/Beutl.Engine/Audio/Graph/Nodes/MixerNode.cs b/src/Beutl.Engine/Audio/Graph/Nodes/MixerNode.cs index 4b5159526b..e1ae5fa55e 100644 --- a/src/Beutl.Engine/Audio/Graph/Nodes/MixerNode.cs +++ b/src/Beutl.Engine/Audio/Graph/Nodes/MixerNode.cs @@ -1,5 +1,4 @@ using System; -using System.Linq; namespace Beutl.Audio.Graph.Nodes; @@ -8,7 +7,8 @@ public sealed class MixerNode : AudioNode private const long BranchLivenessToleranceTicks = TimeSpan.TicksPerMillisecond; private float[] _gains = Array.Empty(); - private TimeSpan[] _branchEndTimes = Array.Empty(); + private readonly Dictionary _branchEndTimes = + new(ReferenceEqualityComparer.Instance); public float[] Gains { @@ -16,27 +16,36 @@ public float[] Gains set => _gains = value ?? Array.Empty(); } - // Group-local end time of each input branch, parallel to Inputs (empty = every branch live, so all - // drain — the back-compat default). A branch whose clip ended before the flush block is dead: its - // tail was recovered at its own clip end, so Flush must skip it instead of re-emitting it late. - public TimeSpan[] BranchEndTimes + /// + /// 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) { - get => _branchEndTimes; - set => _branchEndTimes = value ?? Array.Empty(); + 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 branch is recovered (the base Flush's single-input path cannot reach here). - // - // Known limitation: branches are drained unconditionally. A branch whose clip ended before the - // group's terminal slice — and whose own terminal block landed exactly on its clip boundary (the - // chunk-alignment edge where ClipNode could not self-recover) — still holds a stale tail that this - // emits into the group pad seconds late. Skipping only the branches live through the terminal slice - // needs per-branch clip-liveness the mixer does not track today; recovering at the child's own end is - // the chunk-alignment follow-up. + // 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); @@ -124,6 +133,7 @@ private AudioBuffer Mix(AudioProcessContext context, bool drain) } } + RecordProcessedChannelCount(output.ChannelCount); return output; } catch @@ -147,11 +157,79 @@ private AudioBuffer Mix(AudioProcessContext context, bool drain) private bool IsBranchDead(int index, AudioProcessContext context) { - if (index >= _branchEndTimes.Length) + 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. - return _branchEndTimes[index].Ticks + BranchLivenessToleranceTicks < context.TimeRange.Start.Ticks; + // 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/SoundGroup.cs b/src/Beutl.Engine/Audio/SoundGroup.cs index 9a53fab042..a9291e504c 100644 --- a/src/Beutl.Engine/Audio/SoundGroup.cs +++ b/src/Beutl.Engine/Audio/SoundGroup.cs @@ -78,10 +78,6 @@ public override void Compose(AudioContext context, Sound.Resource resource) // SoundGroupの処理を加える var mixerNode = context.CreateMixerNode(); - // Group-local clip end of each mixer branch, in connect order. A child that ends before the - // group lets the mixer skip it on flush so its already-recovered tail is not re-emitted late. - var branchEndTimes = new List(); - foreach (var child in r.Children) { var original = child.GetOriginal(); @@ -99,12 +95,12 @@ public override void Compose(AudioContext context, Sound.Resource resource) var shiftNode = context.CreateShiftNode(TimeRange.Start); context.Connect(outputNode, shiftNode); context.Connect(shiftNode, mixerNode); - branchEndTimes.Add(original.TimeRange.End - TimeRange.Start); + // 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); } } - mixerNode.BranchEndTimes = [.. branchEndTimes]; - AudioNode currentNode = mixerNode; // SoundGroup全体のGainを適用 diff --git a/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs b/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs index b9e47fd21c..8928604da8 100644 --- a/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs +++ b/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs @@ -300,7 +300,8 @@ public void MixerNode_Flush_SkipsBranchThatEndedBeforeTheTerminalSlice() using var mixer = new MixerNode(); mixer.AddInput(limiterA); mixer.AddInput(limiterB); - mixer.BranchEndTimes = [earlyEnd, groupEnd]; + 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)); @@ -313,6 +314,219 @@ public void MixerNode_Flush_SkipsBranchThatEndedBeforeTheTerminalSlice() } } + [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() { @@ -523,6 +737,51 @@ public void Flush_AnimatedLookaheadDroppingToZero_StillRecoversHeldTail() } } + [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() { @@ -563,6 +822,42 @@ public void Composer_FlushesSoundEndingExactlyAtTheWindowBoundary() "A sound ending exactly at the window boundary must have its limiter tail flushed into the next window's start."); } + [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); + 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); + 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 @@ -724,4 +1019,26 @@ public override AudioBuffer Process(AudioProcessContext context) return buffer; } } + + [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; + } } From 05b8d843afa7ab2a45dbc054d54eb4f67f75823d Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Mon, 27 Jul 2026 02:12:08 +0900 Subject: [PATCH 17/24] refactor!: require composition eligibility snapshots BREAKING CHANGE: CompositionFrame now requires a CompositionEligibility constructor argument. Frame producers must capture target-eligible EngineObject identities independently of time-range intersection and pass CompositionEligibility.Empty when no objects are eligible. --- src/Beutl.Engine/Audio/Composing/Composer.cs | 16 +++- .../Audio/SourceSound.Thumbnails.cs | 6 +- .../Composition/CompositionEligibility.cs | 38 ++++++++ .../Composition/CompositionFrame.cs | 11 ++- .../AudioVisualizerDrawable.cs | 6 +- src/Beutl.ProjectSystem/SceneCompositor.cs | 25 ++++- .../Audio/AudioLatencyCompensationTests.cs | 94 +++++++++++++++++-- .../Engine/Audio/ComposerTests.cs | 12 ++- .../Rendering/RendererExceptionSafetyTests.cs | 3 +- .../ProjectSystem/SceneCompositorTests.cs | 47 ++++++++++ 10 files changed, 237 insertions(+), 21 deletions(-) create mode 100644 src/Beutl.Engine/Composition/CompositionEligibility.cs diff --git a/src/Beutl.Engine/Audio/Composing/Composer.cs b/src/Beutl.Engine/Audio/Composing/Composer.cs index 3a0ec2442e..547f57a3d0 100644 --- a/src/Beutl.Engine/Audio/Composing/Composer.cs +++ b/src/Beutl.Engine/Audio/Composing/Composer.cs @@ -27,6 +27,7 @@ private sealed class AudioNodeEntry : IDisposable public int Version { get; set; } public EventHandler? EditedHandler { get; set; } public TimeRange SoundRange { get; set; } + public required Sound Sound { get; init; } public void Dispose() { @@ -86,7 +87,7 @@ public void Dispose() } // Build final audio graph - var result = BuildFinalOutput(timeRange); + var result = BuildFinalOutput(timeRange, frame.Eligibility); // Record this window's active set so the next window can flush sounds that just ended. _previousEntry.Clear(); @@ -106,7 +107,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(); @@ -125,7 +126,7 @@ public void Dispose() } // Recover the latency tail of any sound that ended on the previous window boundary. - AppendEndedSoundTails(range, buffers); + AppendEndedSoundTails(range, eligibility, buffers); // Mix all buffers mixedBuffer = MixBuffers(buffers); @@ -161,7 +162,10 @@ public void Dispose() // 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, List buffers) + 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), @@ -177,6 +181,8 @@ private void AppendEndedSoundTails(TimeRange range, List buffers) continue; if (!IsSameTimestamp(entry.SoundRange.End, previous.End)) continue; + if (!eligibility.Contains(entry.Sound)) + continue; foreach (var outputNode in outputNodes) { @@ -272,7 +278,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 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..63923f728e 100644 --- a/src/Beutl.Engine/Composition/CompositionFrame.cs +++ b/src/Beutl.Engine/Composition/CompositionFrame.cs @@ -4,7 +4,16 @@ namespace Beutl.Composition; +/// Captures resources and target eligibility for one evaluated composition 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 +/// . +/// 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/SceneCompositor.cs b/src/Beutl.ProjectSystem/SceneCompositor.cs index 9f0f1a3792..1da9896ae7 100644 --- a/src/Beutl.ProjectSystem/SceneCompositor.cs +++ b/src/Beutl.ProjectSystem/SceneCompositor.cs @@ -77,6 +77,7 @@ public void EvaluateElementIntoFlow(Element element) public CompositionFrame EvaluateGraphics(TimeSpan time) { + CompositionEligibility eligibility = CollectEligibility(CompositionTarget.Graphics); using var currentElements = new PooledList(); SortLayers(time, currentElements, CompositionTarget.Graphics); @@ -95,11 +96,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, + eligibility); } public CompositionFrame EvaluateAudio(TimeRange timeRange) { + CompositionEligibility eligibility = CollectEligibility(CompositionTarget.Audio); using var currentElements = new PooledList(); SortLayers(timeRange, currentElements, CompositionTarget.Audio); @@ -118,7 +124,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/Audio/AudioLatencyCompensationTests.cs b/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs index 8928604da8..fda95eae5b 100644 --- a/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs +++ b/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs @@ -803,11 +803,20 @@ public void Composer_FlushesSoundEndingExactlyAtTheWindowBoundary() using var composer = new Composer { SampleRate = SampleRate }; var window1 = new TimeRange(TimeSpan.Zero, oneSecond); - var frame1 = new CompositionFrame(ImmutableArray.Create(resource), window1, default); + 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); + var frame2 = new CompositionFrame( + ImmutableArray.Empty, + window2, + default, + eligibility); using var buffer2 = composer.Compose(window2, frame2); Assert.That(buffer2, Is.Not.Null); @@ -822,6 +831,49 @@ public void Composer_FlushesSoundEndingExactlyAtTheWindowBoundary() "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_DoesNotFlushSoundThatDisappearsBeforeItsNaturalEnd() { @@ -840,13 +892,21 @@ public void Composer_DoesNotFlushSoundThatDisappearsBeforeItsNaturalEnd() using var composer = new Composer { SampleRate = SampleRate }; var window1 = new TimeRange(TimeSpan.Zero, oneSecond); - var frame1 = new CompositionFrame(ImmutableArray.Create(resource), window1, default); + 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); + var frame2 = new CompositionFrame( + ImmutableArray.Empty, + window2, + default, + CompositionEligibility.Empty); using var buffer2 = composer.Compose(window2, frame2); Assert.That(buffer2, Is.Not.Null); @@ -879,12 +939,21 @@ public void Composer_DoesNotFlushEndedSoundTail_AfterNonContiguousSeek() using var composer = new Composer { SampleRate = SampleRate }; var window1 = new TimeRange(TimeSpan.Zero, oneSecond); - var frame1 = new CompositionFrame(ImmutableArray.Create(resource), window1, default); + 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); + var frame2 = new CompositionFrame( + ImmutableArray.Empty, + window2, + default, + eligibility); using var buffer2 = composer.Compose(window2, frame2); Assert.That(buffer2, Is.Not.Null); @@ -916,14 +985,23 @@ public void Composer_InvalidateCache_SuppressesEndedSoundTailFlush() using var composer = new Composer { SampleRate = SampleRate }; var window1 = new TimeRange(TimeSpan.Zero, oneSecond); - var frame1 = new CompositionFrame(ImmutableArray.Create(resource), window1, default); + 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); + var frame2 = new CompositionFrame( + ImmutableArray.Empty, + window2, + default, + eligibility); using var buffer2 = composer.Compose(window2, frame2); Assert.That(buffer2, Is.Not.Null); diff --git a/tests/Beutl.UnitTests/Engine/Audio/ComposerTests.cs b/tests/Beutl.UnitTests/Engine/Audio/ComposerTests.cs index ab48cdc1e8..84a671896d 100644 --- a/tests/Beutl.UnitTests/Engine/Audio/ComposerTests.cs +++ b/tests/Beutl.UnitTests/Engine/Audio/ComposerTests.cs @@ -21,7 +21,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 +42,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/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..7799c7433b 100644 --- a/tests/Beutl.UnitTests/ProjectSystem/SceneCompositorTests.cs +++ b/tests/Beutl.UnitTests/ProjectSystem/SceneCompositorTests.cs @@ -92,6 +92,32 @@ public void EvaluateAudio_DisabledElement_IsExcluded() Assert.That(frame.Objects.Length, Is.EqualTo(1)); Assert.That(frame.Objects[0].GetOriginal(), Is.SameAs(enabled.Objects[0])); + Assert.That(frame.Eligibility.Contains(enabled.Objects[0]), Is.True); + Assert.That(frame.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))); + + Assert.That(frame.Objects, Is.Empty); + Assert.That(frame.Eligibility.Contains(element.Objects[0]), Is.True, + "Eligibility ignores time intersection so Composer can identify a natural clip end."); } finally { @@ -314,6 +340,8 @@ public void EvaluateAudio_AudioMutedLayer_ExcludedFromAudio() Assert.That(frame.Objects.Length, Is.EqualTo(1)); Assert.That(frame.Objects[0].GetOriginal(), Is.SameAs(z1.Objects[0])); + Assert.That(frame.Eligibility.Contains(z0.Objects[0]), Is.False); + Assert.That(frame.Eligibility.Contains(z1.Objects[0]), Is.True); } finally { @@ -321,6 +349,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() { @@ -639,6 +678,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 From dfeca8e16d2ea2746d512248e988b181d10548de Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Mon, 27 Jul 2026 02:44:15 +0900 Subject: [PATCH 18/24] fix: preserve audio tails across graph transitions --- src/Beutl.Engine/Audio/Graph/AudioNode.cs | 48 +++- .../Audio/Graph/AudioProcessContext.cs | 50 ++++ .../Audio/Graph/Nodes/ClipNode.cs | 4 +- .../Audio/Graph/Nodes/SpeedNode.cs | 184 ++++++++++--- .../Audio/AudioLatencyCompensationTests.cs | 253 ++++++++++++++++++ .../Engine/Audio/AudioNodeTests.cs | 144 ++++++++++ .../Engine/Audio/AudioProcessContextTests.cs | 51 ++++ 7 files changed, 691 insertions(+), 43 deletions(-) create mode 100644 tests/Beutl.UnitTests/Engine/Audio/AudioNodeTests.cs diff --git a/src/Beutl.Engine/Audio/Graph/AudioNode.cs b/src/Beutl.Engine/Audio/Graph/AudioNode.cs index d0836e2030..1c33c51a16 100644 --- a/src/Beutl.Engine/Audio/Graph/AudioNode.cs +++ b/src/Beutl.Engine/Audio/Graph/AudioNode.cs @@ -14,8 +14,18 @@ public void AddInput(AudioNode input) if (IndexOfInput(input) >= 0) return; + AudioNode[] previousInputs = [.. _inputs]; _inputs.Add(input); - OnInputAdded(input, _inputs.Count - 1); + try + { + OnInputAdded(input, _inputs.Count - 1); + } + catch + { + _inputs.Clear(); + _inputs.AddRange(previousInputs); + throw; + } } public void RemoveInput(AudioNode input) @@ -26,31 +36,57 @@ public void RemoveInput(AudioNode input) if (index < 0) return; + AudioNode[] previousInputs = [.. _inputs]; _inputs.RemoveAt(index); - OnInputRemoved(input, index); + try + { + OnInputRemoved(input, index); + } + catch + { + _inputs.Clear(); + _inputs.AddRange(previousInputs); + throw; + } } public void ClearInputs() { + AudioNode[] previousInputs = [.. _inputs]; _inputs.Clear(); - OnInputsCleared(); + try + { + OnInputsCleared(); + } + catch + { + _inputs.Clear(); + _inputs.AddRange(previousInputs); + throw; + } } /// Called after an input is appended, allowing derived nodes to keep connection metadata - /// aligned with . + /// 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 . + /// 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() { } 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 7d861e5113..e32ffaf259 100644 --- a/src/Beutl.Engine/Audio/Graph/Nodes/ClipNode.cs +++ b/src/Beutl.Engine/Audio/Graph/Nodes/ClipNode.cs @@ -126,7 +126,9 @@ private void AppendFlushedTail(AudioProcessContext context, AudioBuffer newBuffe return; var drainContext = new AudioProcessContext( - new TimeRange(Duration, TimeSpan.FromSeconds((double)drainCount / context.SampleRate)), + new TimeRange( + Duration, + AudioProcessContext.GetDurationForSampleCount(drainCount, context.SampleRate)), context.SampleRate, context.AnimationSampler, context.OriginalTimeRange); diff --git a/src/Beutl.Engine/Audio/Graph/Nodes/SpeedNode.cs b/src/Beutl.Engine/Audio/Graph/Nodes/SpeedNode.cs index bae2e4d99b..e8822b2b44 100644 --- a/src/Beutl.Engine/Audio/Graph/Nodes/SpeedNode.cs +++ b/src/Beutl.Engine/Audio/Graph/Nodes/SpeedNode.cs @@ -12,24 +12,27 @@ 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; } - // Known limitation (same class as ResampleNode's): no Flush override. Process streams its input - // through a resampler at a derived source position/rate; the inherited flush instead forwards the - // output context straight upstream, so a latency-bearing node placed UPSTREAM of this SpeedNode - // would see a discontinuity on flush, re-anchor, and drop its tail. The built-in Sound chain puts - // SpeedNode before the effects, so a limiter is always downstream — this only bites a custom graph - // that time-stretches after a latency-bearing node; a re-anchoring Flush override is the follow-up. public override AudioBuffer Process(AudioProcessContext context) + => ProcessCore(context, draining: false); + + public override AudioBuffer Flush(AudioProcessContext context) + => ProcessCore(context, draining: 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."); @@ -55,27 +58,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; @@ -116,7 +151,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 { @@ -186,14 +226,17 @@ 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 // between chunks, so the source must be fed as one unbroken stream — re-seeking every chunk // desynchronised the read position from that history and clicked at each boundary. private long _srcReadPos; // absolute next source sample to feed, in the source timeline + private TimeSpan _nextSourceStart; 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) { @@ -208,6 +251,38 @@ 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; + _nextSourceStart = context.TimeRange.End; + _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 @@ -221,6 +296,7 @@ private bool BeginStream(double outputStartSeconds, double sourceStartSeconds, b { _rs.Reset(); _srcReadPos = (long)Math.Round(sourceStartSeconds * _sampleRate); + _nextSourceStart = TimeSpan.FromSeconds((_srcReadPos + 0.5) / _sampleRate); _initialized = true; } @@ -230,17 +306,22 @@ 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. + // Seeks initialize _nextSourceStart with a half-sample bias so a source that truncates + // TimeSpan back to an index lands on _srcReadPos, while contiguous reads retain the exact + // previous range end. A unity passthrough seeds it from the real upstream context end. var range = new TimeRange( - TimeSpan.FromSeconds((_srcReadPos + 0.5) / _sampleRate), - TimeSpan.FromSeconds(count / (double)_sampleRate)); + _nextSourceStart, + AudioProcessContext.GetDurationForSampleCount(count, _sampleRate)); var subContext = new AudioProcessContext( range, _sampleRate, @@ -250,7 +331,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); @@ -261,10 +344,17 @@ private int Read(float[] buffer, int interleavedOffset, int count, AudioProcessC } _srcReadPos += samplesToRead; + _nextSourceStart = range.Start + + AudioProcessContext.GetDurationForSampleCount(samplesToRead, _sampleRate); 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; @@ -272,16 +362,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); @@ -293,7 +389,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); @@ -308,6 +404,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 @@ -319,10 +416,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 @@ -342,12 +447,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); @@ -360,6 +463,7 @@ public AudioBuffer ProcessBufferWithVariableSpeed( WriteAndPad(output, dst, framesDone, expectedOut); // Advance only on full success. _nextOutputStart = outputStart + (double)expectedOut / _sampleRate; + _sourceCursorMatchesOutputTimeline = false; return output; } catch @@ -369,6 +473,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/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs b/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs index fda95eae5b..f808e4a8b7 100644 --- a/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs +++ b/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs @@ -245,6 +245,156 @@ public void Flush_AppliesDownstreamProcessing_ToTheTail() } } + [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() { @@ -641,6 +791,26 @@ public void NestedClipNode_Flush_ContinuesAfterPartialTailAppend() + "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() { @@ -1098,6 +1268,89 @@ public override AudioBuffer Process(AudioProcessContext context) } } + 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() diff --git a/tests/Beutl.UnitTests/Engine/Audio/AudioNodeTests.cs b/tests/Beutl.UnitTests/Engine/Audio/AudioNodeTests.cs new file mode 100644 index 0000000000..04eb1d68b9 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Audio/AudioNodeTests.cs @@ -0,0 +1,144 @@ +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 { ThrowOnAdd = true }; + using var input = new ValueNode(); + + Assert.Throws(() => node.AddInput(input)); + Assert.That(node.Inputs, Is.Empty); + Assert.That(node.MetadataInputs, Is.Empty); + + node.ThrowOnAdd = false; + node.AddInput(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)); + } + + [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)] From 06a023e2234ad8053b0c879a96a54979beeafb2c Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Mon, 27 Jul 2026 02:59:01 +0900 Subject: [PATCH 19/24] refactor!: clarify audio flush and eligibility contracts BREAKING CHANGE: In Beutl.Engine, CompositionFrame.Eligibility is now nullable: null means no snapshot was captured, while CompositionEligibility.Empty means a captured empty set. Audio composition requires a non-null eligibility snapshot. Multi-input AudioNode subclasses must override Flush with their own merge semantics; ProcessTail owns its input and must dispose it before returning a different buffer or throwing. Beutl.ProjectSystem graphics producers should pass null instead of building an unused eligibility snapshot. --- src/Beutl.Engine/Audio/Composing/Composer.cs | 5 +++- src/Beutl.Engine/Audio/Graph/AudioNode.cs | 27 ++++--------------- .../Audio/Graph/Nodes/LimiterNode.cs | 13 +++++---- .../Composition/CompositionFrame.cs | 7 ++--- src/Beutl.ProjectSystem/SceneCompositor.cs | 3 +-- .../Engine/Audio/ComposerTests.cs | 17 ++++++++++++ .../ProjectSystem/SceneCompositorTests.cs | 18 +++++++++---- 7 files changed, 52 insertions(+), 38 deletions(-) diff --git a/src/Beutl.Engine/Audio/Composing/Composer.cs b/src/Beutl.Engine/Audio/Composing/Composer.cs index 547f57a3d0..3168dd9208 100644 --- a/src/Beutl.Engine/Audio/Composing/Composer.cs +++ b/src/Beutl.Engine/Audio/Composing/Composer.cs @@ -78,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) @@ -87,7 +90,7 @@ public void Dispose() } // Build final audio graph - var result = BuildFinalOutput(timeRange, frame.Eligibility); + var result = BuildFinalOutput(timeRange, eligibility); // Record this window's active set so the next window can flush sounds that just ended. _previousEntry.Clear(); diff --git a/src/Beutl.Engine/Audio/Graph/AudioNode.cs b/src/Beutl.Engine/Audio/Graph/AudioNode.cs index 1c33c51a16..b4ce735b84 100644 --- a/src/Beutl.Engine/Audio/Graph/AudioNode.cs +++ b/src/Beutl.Engine/Audio/Graph/AudioNode.cs @@ -114,8 +114,10 @@ private int IndexOfInput(AudioNode input) /// 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 - /// returns a fresh buffer takes ownership of and disposes it, exactly as its - /// Process already does. + /// 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; @@ -155,26 +157,7 @@ public virtual AudioBuffer Flush(AudioProcessContext context) if (_inputs.Count == 1) { - AudioBuffer drained = _inputs[0].Flush(context); - AudioBuffer result; - try - { - result = ProcessTail(drained, context, draining: true); - } - catch - { - // ProcessTail threw before taking ownership; dispose the drain we pulled (Dispose is - // idempotent, so a transforming node that already consumed it is unaffected). - drained.Dispose(); - throw; - } - - // Pass-through ProcessTail hands back the same instance, which we must not dispose since we - // return it; a transforming ProcessTail already consumed `drained` and returns a fresh one. - if (!ReferenceEquals(result, drained)) - drained.Dispose(); - - return result; + return ProcessTail(_inputs[0].Flush(context), context, draining: true); } throw new InvalidOperationException( diff --git a/src/Beutl.Engine/Audio/Graph/Nodes/LimiterNode.cs b/src/Beutl.Engine/Audio/Graph/Nodes/LimiterNode.cs index 3f2afd0feb..565bf20694 100644 --- a/src/Beutl.Engine/Audio/Graph/Nodes/LimiterNode.cs +++ b/src/Beutl.Engine/Audio/Graph/Nodes/LimiterNode.cs @@ -70,11 +70,11 @@ 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(input, context, draining: false); + 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 @@ -82,6 +82,9 @@ public override AudioBuffer Process(AudioProcessContext context) // 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( $"LimiterNode: sample rate mismatch. context={context.SampleRate}, input={input.SampleRate}."); diff --git a/src/Beutl.Engine/Composition/CompositionFrame.cs b/src/Beutl.Engine/Composition/CompositionFrame.cs index 63923f728e..b6a111ea8c 100644 --- a/src/Beutl.Engine/Composition/CompositionFrame.cs +++ b/src/Beutl.Engine/Composition/CompositionFrame.cs @@ -4,16 +4,17 @@ namespace Beutl.Composition; -/// Captures resources and target eligibility for one evaluated composition range. +/// 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, - CompositionEligibility Eligibility); + CompositionEligibility? Eligibility); diff --git a/src/Beutl.ProjectSystem/SceneCompositor.cs b/src/Beutl.ProjectSystem/SceneCompositor.cs index 1da9896ae7..02cdf1fcd4 100644 --- a/src/Beutl.ProjectSystem/SceneCompositor.cs +++ b/src/Beutl.ProjectSystem/SceneCompositor.cs @@ -77,7 +77,6 @@ public void EvaluateElementIntoFlow(Element element) public CompositionFrame EvaluateGraphics(TimeSpan time) { - CompositionEligibility eligibility = CollectEligibility(CompositionTarget.Graphics); using var currentElements = new PooledList(); SortLayers(time, currentElements, CompositionTarget.Graphics); @@ -100,7 +99,7 @@ public CompositionFrame EvaluateGraphics(TimeSpan time) [.. allResources], new(time, TimeSpan.FromTicks(1)), Scene.FrameSize, - eligibility); + null); } public CompositionFrame EvaluateAudio(TimeRange timeRange) diff --git a/tests/Beutl.UnitTests/Engine/Audio/ComposerTests.cs b/tests/Beutl.UnitTests/Engine/Audio/ComposerTests.cs index 84a671896d..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() { diff --git a/tests/Beutl.UnitTests/ProjectSystem/SceneCompositorTests.cs b/tests/Beutl.UnitTests/ProjectSystem/SceneCompositorTests.cs index 7799c7433b..704e7dcf30 100644 --- a/tests/Beutl.UnitTests/ProjectSystem/SceneCompositorTests.cs +++ b/tests/Beutl.UnitTests/ProjectSystem/SceneCompositorTests.cs @@ -89,11 +89,13 @@ 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(frame.Eligibility.Contains(enabled.Objects[0]), Is.True); - Assert.That(frame.Eligibility.Contains(disabled.Objects[0]), Is.False); + Assert.That(eligibility.Contains(enabled.Objects[0]), Is.True); + Assert.That(eligibility.Contains(disabled.Objects[0]), Is.False); } finally { @@ -114,9 +116,11 @@ public void EvaluateAudio_OutOfRangeElement_RemainsEligible() 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(frame.Eligibility.Contains(element.Objects[0]), Is.True, + Assert.That(eligibility.Contains(element.Objects[0]), Is.True, "Eligibility ignores time intersection so Composer can identify a natural clip end."); } finally @@ -337,11 +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(frame.Eligibility.Contains(z0.Objects[0]), Is.False); - Assert.That(frame.Eligibility.Contains(z1.Objects[0]), Is.True); + Assert.That(eligibility.Contains(z0.Objects[0]), Is.False); + Assert.That(eligibility.Contains(z1.Objects[0]), Is.True); } finally { @@ -377,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 { From 215e7ae62e27d4e8b84c5a331a1e13a8f676f808 Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Mon, 27 Jul 2026 03:17:32 +0900 Subject: [PATCH 20/24] fix: scale audio tail budgets through speed changes --- .../Animation/Easings/BackEaseIn.cs | 9 ++ .../Animation/Easings/BackEaseInOut.cs | 7 + .../Animation/Easings/BackEaseOut.cs | 7 + .../Animation/Easings/BounceEaseIn.cs | 2 +- .../Animation/Easings/BounceEaseInOut.cs | 2 +- .../Animation/Easings/BounceEaseOut.cs | 2 +- .../Animation/Easings/CircularEaseIn.cs | 2 +- .../Animation/Easings/CircularEaseInOut.cs | 2 +- .../Animation/Easings/CircularEaseOut.cs | 2 +- .../Animation/Easings/CubicEaseIn.cs | 2 +- .../Animation/Easings/CubicEaseInOut.cs | 2 +- .../Animation/Easings/CubicEaseOut.cs | 2 +- src/Beutl.Engine/Animation/Easings/Easing.cs | 15 ++ .../Animation/Easings/ElasticEaseIn.cs | 7 + .../Animation/Easings/ElasticEaseInOut.cs | 7 + .../Animation/Easings/ElasticEaseOut.cs | 7 + .../Animation/Easings/ExponentialEaseIn.cs | 2 +- .../Animation/Easings/ExponentialEaseInOut.cs | 2 +- .../Animation/Easings/ExponentialEaseOut.cs | 2 +- .../Animation/Easings/HoldEasing.cs | 2 +- .../Animation/Easings/LinearEasing.cs | 2 +- .../Animation/Easings/QuadraticEaseIn.cs | 2 +- .../Animation/Easings/QuadraticEaseInOut.cs | 2 +- .../Animation/Easings/QuadraticEaseOut.cs | 2 +- .../Animation/Easings/QuarticEaseIn.cs | 2 +- .../Animation/Easings/QuarticEaseInOut.cs | 2 +- .../Animation/Easings/QuarticEaseOut.cs | 2 +- .../Animation/Easings/QuinticEaseIn.cs | 2 +- .../Animation/Easings/QuinticEaseInOut.cs | 2 +- .../Animation/Easings/QuinticEaseOut.cs | 2 +- .../Animation/Easings/SineEaseIn.cs | 2 +- .../Animation/Easings/SineEaseInOut.cs | 2 +- .../Animation/Easings/SineEaseOut.cs | 2 +- .../Animation/Easings/SplineEasing.cs | 8 + .../Animation/Easings/UnitRangeEasing.cs | 12 ++ src/Beutl.Engine/Audio/Graph/AudioNode.cs | 15 +- .../Audio/Graph/Nodes/SpeedNode.cs | 83 +++++++++++ .../Animation/EasingOutputRangeTests.cs | 74 ++++++++++ .../Engine/Audio/AudioLatencyTests.cs | 138 ++++++++++++++++++ 39 files changed, 411 insertions(+), 30 deletions(-) create mode 100644 src/Beutl.Engine/Animation/Easings/UnitRangeEasing.cs create mode 100644 tests/Beutl.UnitTests/Engine/Animation/EasingOutputRangeTests.cs 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/Graph/AudioNode.cs b/src/Beutl.Engine/Audio/Graph/AudioNode.cs index b4ce735b84..73a9c66536 100644 --- a/src/Beutl.Engine/Audio/Graph/AudioNode.cs +++ b/src/Beutl.Engine/Audio/Graph/AudioNode.cs @@ -183,6 +183,8 @@ public virtual int GetLatencySamples(int sampleRate) /// 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) @@ -194,12 +196,17 @@ public virtual int GetTotalLatencySamples(int sampleRate) int upstream = 0; foreach (AudioNode input in _inputs) { - int total = input.GetTotalLatencySamples(sampleRate); - if (total > upstream) - upstream = total; + int inputTotal = input.GetTotalLatencySamples(sampleRate); + if (inputTotal > upstream) + upstream = inputTotal; } - return GetLatencySamples(sampleRate) + upstream; + 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) diff --git a/src/Beutl.Engine/Audio/Graph/Nodes/SpeedNode.cs b/src/Beutl.Engine/Audio/Graph/Nodes/SpeedNode.cs index e8822b2b44..949912a5e8 100644 --- a/src/Beutl.Engine/Audio/Graph/Nodes/SpeedNode.cs +++ b/src/Beutl.Engine/Audio/Graph/Nodes/SpeedNode.cs @@ -29,6 +29,89 @@ public override AudioBuffer Process(AudioProcessContext context) 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); 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/AudioLatencyTests.cs b/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyTests.cs index 9618028817..8ad09d3d34 100644 --- a/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyTests.cs +++ b/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyTests.cs @@ -1,4 +1,5 @@ using Beutl.Animation; +using Beutl.Animation.Easings; using Beutl.Audio; using Beutl.Audio.Effects; using Beutl.Audio.Graph; @@ -7,6 +8,7 @@ using Beutl.Logging; using Beutl.Media; using Microsoft.Extensions.Logging; +using Moq; using static Beutl.UnitTests.Engine.Audio.AudioTestBuffers; @@ -271,6 +273,135 @@ public void GetTotalLatencySamples_OverFanIn_TakesMax() 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) @@ -291,5 +422,12 @@ public void GetLatencySamples_NonPositiveSampleRate_Throws(int sampleRate) // 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; } } From e64d0a790b6551a5e9a6bf2bcec9cb52ff40e80d Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Mon, 27 Jul 2026 03:25:23 +0900 Subject: [PATCH 21/24] fix: keep speed source reads sample-exact --- .../Audio/Graph/Nodes/SpeedNode.cs | 18 ++++----- .../Engine/Audio/SpeedNodeTests.cs | 40 +++++++++++++++++++ 2 files changed, 49 insertions(+), 9 deletions(-) diff --git a/src/Beutl.Engine/Audio/Graph/Nodes/SpeedNode.cs b/src/Beutl.Engine/Audio/Graph/Nodes/SpeedNode.cs index 949912a5e8..9c1c8ba22f 100644 --- a/src/Beutl.Engine/Audio/Graph/Nodes/SpeedNode.cs +++ b/src/Beutl.Engine/Audio/Graph/Nodes/SpeedNode.cs @@ -316,7 +316,6 @@ private sealed class SpeedProcessor // between chunks, so the source must be fed as one unbroken stream — re-seeking every chunk // desynchronised the read position from that history and clicked at each boundary. private long _srcReadPos; // absolute next source sample to feed, in the source timeline - private TimeSpan _nextSourceStart; private double _nextOutputStart; // expected output start (seconds) of the next contiguous chunk private bool _initialized; private bool _sourceCursorMatchesOutputTimeline; @@ -341,7 +340,6 @@ public void TrackPassthrough(AudioProcessContext context, int sampleCount) double outputStart = context.TimeRange.Start.TotalSeconds; _rs.Reset(); _srcReadPos = (long)Math.Round(outputStart * _sampleRate) + sampleCount; - _nextSourceStart = context.TimeRange.End; _nextOutputStart = outputStart + (double)sampleCount / _sampleRate; _currentSpeed = 1f; _resamplingMode = ResamplingMode.Passthrough; @@ -379,7 +377,6 @@ private bool BeginStream(double outputStartSeconds, double sourceStartSeconds, b { _rs.Reset(); _srcReadPos = (long)Math.Round(sourceStartSeconds * _sampleRate); - _nextSourceStart = TimeSpan.FromSeconds((_srcReadPos + 0.5) / _sampleRate); _initialized = true; } @@ -399,11 +396,16 @@ private int Read( if (count <= 0) return 0; - // Seeks initialize _nextSourceStart with a half-sample bias so a source that truncates - // TimeSpan back to an index lands on _srcReadPos, while contiguous reads retain the exact - // previous range end. A unity passthrough seeds it from the real upstream context end. + // 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( - _nextSourceStart, + sourceStart, AudioProcessContext.GetDurationForSampleCount(count, _sampleRate)); var subContext = new AudioProcessContext( range, @@ -427,8 +429,6 @@ private int Read( } _srcReadPos += samplesToRead; - _nextSourceStart = range.Start - + AudioProcessContext.GetDurationForSampleCount(samplesToRead, _sampleRate); return samplesToRead; } 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] From 9f5635ba4b9a4d85774f8d9dd5f5e6c0254ead01 Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Mon, 27 Jul 2026 03:34:22 +0900 Subject: [PATCH 22/24] fix: saturate grouped audio effect latency --- src/Beutl.Engine/Audio/Effects/AudioEffect.cs | 4 +- .../Audio/Effects/AudioEffectGroup.cs | 24 +++++++++++- src/Beutl.Engine/Audio/Graph/AudioNode.cs | 4 +- .../Engine/Audio/AudioLatencyTests.cs | 37 +++++++++++++++++++ 4 files changed, 65 insertions(+), 4 deletions(-) diff --git a/src/Beutl.Engine/Audio/Effects/AudioEffect.cs b/src/Beutl.Engine/Audio/Effects/AudioEffect.cs index 29ee2f541c..e867926dfb 100644 --- a/src/Beutl.Engine/Audio/Effects/AudioEffect.cs +++ b/src/Beutl.Engine/Audio/Effects/AudioEffect.cs @@ -20,7 +20,9 @@ public abstract partial class AudioEffect : EngineObject /// 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. + /// 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) diff --git a/src/Beutl.Engine/Audio/Effects/AudioEffectGroup.cs b/src/Beutl.Engine/Audio/Effects/AudioEffectGroup.cs index 863df1b118..2b93516a53 100644 --- a/src/Beutl.Engine/Audio/Effects/AudioEffectGroup.cs +++ b/src/Beutl.Engine/Audio/Effects/AudioEffectGroup.cs @@ -29,7 +29,27 @@ public override int GetLatencySamples(int sampleRate) if (!IsEnabled) return 0; - return Children.Where(item => item.IsEnabled) - .Sum(item => item.GetLatencySamples(sampleRate)); + 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/Graph/AudioNode.cs b/src/Beutl.Engine/Audio/Graph/AudioNode.cs index 73a9c66536..092bc66662 100644 --- a/src/Beutl.Engine/Audio/Graph/AudioNode.cs +++ b/src/Beutl.Engine/Audio/Graph/AudioNode.cs @@ -168,7 +168,9 @@ public virtual AudioBuffer Flush(AudioProcessContext context) /// 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. + /// (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) diff --git a/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyTests.cs b/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyTests.cs index 8ad09d3d34..d9023feb3e 100644 --- a/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyTests.cs +++ b/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyTests.cs @@ -237,6 +237,35 @@ public void AudioEffectGroup_GetLatencySamples_DisabledGroupReportsZero() 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() { @@ -430,4 +459,12 @@ 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; } From e755eb3e14075d4e779879db0e98b7b67fabb57d Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Mon, 27 Jul 2026 03:40:13 +0900 Subject: [PATCH 23/24] fix: discard cached audio tails after sound moves --- src/Beutl.Engine/Audio/Composing/Composer.cs | 2 + .../Audio/AudioLatencyCompensationTests.cs | 46 +++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/src/Beutl.Engine/Audio/Composing/Composer.cs b/src/Beutl.Engine/Audio/Composing/Composer.cs index 3168dd9208..e678363b17 100644 --- a/src/Beutl.Engine/Audio/Composing/Composer.cs +++ b/src/Beutl.Engine/Audio/Composing/Composer.cs @@ -184,6 +184,8 @@ private void AppendEndedSoundTails( continue; if (!IsSameTimestamp(entry.SoundRange.End, previous.End)) continue; + if (entry.Sound.TimeRange != entry.SoundRange) + continue; if (!eligibility.Contains(entry.Sound)) continue; diff --git a/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs b/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs index f808e4a8b7..d60cd82865 100644 --- a/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs +++ b/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs @@ -1044,6 +1044,52 @@ public void Composer_DoesNotFlushSoundThatBecomesIneligibleAtItsNaturalEnd() } } + [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_DoesNotFlushSoundThatDisappearsBeforeItsNaturalEnd() { From 5c1e504027c145e7ce31d70ce8737dd7e91b44c7 Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Mon, 27 Jul 2026 15:07:46 +0900 Subject: [PATCH 24/24] fix(review): harden audio cache and input updates --- src/Beutl.Engine/Audio/Composing/Composer.cs | 2 + src/Beutl.Engine/Audio/Graph/AudioNode.cs | 7 ++- .../Audio/AudioLatencyCompensationTests.cs | 46 +++++++++++++++++++ .../Engine/Audio/AudioNodeTests.cs | 20 +++++--- 4 files changed, 65 insertions(+), 10 deletions(-) diff --git a/src/Beutl.Engine/Audio/Composing/Composer.cs b/src/Beutl.Engine/Audio/Composing/Composer.cs index e678363b17..fc3370ff70 100644 --- a/src/Beutl.Engine/Audio/Composing/Composer.cs +++ b/src/Beutl.Engine/Audio/Composing/Composer.cs @@ -180,6 +180,8 @@ private void AppendEndedSoundTails( { if (_currentEntry.Contains(entry)) continue; + if (entry.IsDirty) + continue; if (entry.OutputNodes is not { } outputNodes) continue; if (!IsSameTimestamp(entry.SoundRange.End, previous.End)) diff --git a/src/Beutl.Engine/Audio/Graph/AudioNode.cs b/src/Beutl.Engine/Audio/Graph/AudioNode.cs index 092bc66662..049bdee2f3 100644 --- a/src/Beutl.Engine/Audio/Graph/AudioNode.cs +++ b/src/Beutl.Engine/Audio/Graph/AudioNode.cs @@ -14,16 +14,15 @@ public void AddInput(AudioNode input) if (IndexOfInput(input) >= 0) return; - AudioNode[] previousInputs = [.. _inputs]; + int index = _inputs.Count; _inputs.Add(input); try { - OnInputAdded(input, _inputs.Count - 1); + OnInputAdded(input, index); } catch { - _inputs.Clear(); - _inputs.AddRange(previousInputs); + _inputs.RemoveAt(index); throw; } } diff --git a/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs b/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs index d60cd82865..3ed243a499 100644 --- a/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs +++ b/tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs @@ -1090,6 +1090,52 @@ public void Composer_DoesNotFlushCachedTailAfterSoundMoves() } } + [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() { diff --git a/tests/Beutl.UnitTests/Engine/Audio/AudioNodeTests.cs b/tests/Beutl.UnitTests/Engine/Audio/AudioNodeTests.cs index 04eb1d68b9..a4647f8bec 100644 --- a/tests/Beutl.UnitTests/Engine/Audio/AudioNodeTests.cs +++ b/tests/Beutl.UnitTests/Engine/Audio/AudioNodeTests.cs @@ -9,19 +9,27 @@ public class AudioNodeTests [Test] public void AddInput_WhenHookThrows_RestoresTopologyAndAllowsRetry() { - using var node = new ThrowingHookNode { ThrowOnAdd = true }; + 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, Is.Empty); - Assert.That(node.MetadataInputs, Is.Empty); + 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(1)); - Assert.That(node.Inputs[0], Is.SameAs(input)); - Assert.That(node.MetadataInputs[0], Is.SameAs(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]