feat(engine): audio effect latency reporting + lookahead tail compensation#1998
feat(engine): audio effect latency reporting + lookahead tail compensation#1998yuto-trd wants to merge 13 commits into
Conversation
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
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds ChangesAudio Latency Query and Tail Recovery API
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Pull request overview
Adds a report-only audio latency API to the Beutl audio graph/effects layer so hosts and pipelines can query per-node/effect latency (notably Limiter lookahead) as a prerequisite for future compensation.
Changes:
- Introduces
GetLatencySamples(int sampleRate)onAudioNodeandAudioEffect(defaulting to0) and an aggregateAudioNode.GetTotalLatencySamples(...)that folds upstream latency vialocal + max(inputs). - Implements limiter lookahead latency reporting via
LimiterParameters.ToLatencySamples(...)and overrides onLimiterNode/LimiterEffect; addsAudioEffectGroupsummation behavior mirroringCreateNode. - Adds NUnit coverage for limiter latency behavior, effect/group aggregation semantics, and non-positive sample rate guards.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/Beutl.UnitTests/Engine/Audio/AudioLatencyTests.cs | New fixture validating limiter latency reporting, aggregation rules, and argument validation. |
| src/Beutl.Engine/Audio/Graph/AudioNode.cs | Adds node latency reporting APIs and upstream aggregation method. |
| src/Beutl.Engine/Audio/Graph/Nodes/LimiterNode.cs | Overrides node latency reporting to expose lookahead delay. |
| src/Beutl.Engine/Audio/Effects/AudioEffect.cs | Adds effect-level latency reporting API for pre-graph queries. |
| src/Beutl.Engine/Audio/Effects/AudioEffectGroup.cs | Implements group latency as sum of enabled children (serial cascade). |
| src/Beutl.Engine/Audio/Effects/LimiterEffect.cs | Overrides effect latency reporting to match limiter lookahead (when enabled). |
| src/Beutl.Engine/Audio/Effects/LimiterParameters.cs | Adds shared helper to compute/clamp lookahead latency in samples. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
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
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
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/Beutl.Engine/Audio/Graph/AudioNode.cs`:
- Around line 46-50: In the Flush method of the AudioNode class, modify the
logic to throw an exception when there are multiple inputs (_inputs.Count > 1)
instead of silently returning a silent buffer. Keep the existing fallback
behavior that returns silence when there are zero inputs, but add a throw
statement for the multi-input case to force explicit merge or drain semantics in
subclasses that override Flush with multiple inputs.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: f7b50aeb-087e-4a90-971d-93276c749796
📒 Files selected for processing (4)
src/Beutl.Engine/Audio/Graph/AudioNode.cssrc/Beutl.Engine/Audio/Graph/Nodes/ClipNode.cssrc/Beutl.Engine/Audio/Graph/Nodes/LimiterNode.cstests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c7f1bda716
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
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
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/Beutl.Engine/Audio/Graph/Nodes/LimiterNode.cs`:
- Around line 74-78: The drained buffer is leaking when ProcessTail throws an
exception during the flush operation. In the AudioNode.Flush method, wrap the
ProcessTail call in a try-finally block to ensure the drained buffer is properly
disposed regardless of whether ProcessTail succeeds or throws an exception. The
finally block should dispose of the drained buffer after ProcessTail completes,
whether successfully or via exception.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 8a860065-c4a2-4d25-b6e2-f7d65fbaa530
📒 Files selected for processing (10)
src/Beutl.Engine/Audio/Graph/AudioNode.cssrc/Beutl.Engine/Audio/Graph/Nodes/ClipNode.cssrc/Beutl.Engine/Audio/Graph/Nodes/CompressorNode.cssrc/Beutl.Engine/Audio/Graph/Nodes/DelayNode.cssrc/Beutl.Engine/Audio/Graph/Nodes/EqualizerNode.cssrc/Beutl.Engine/Audio/Graph/Nodes/GainNode.cssrc/Beutl.Engine/Audio/Graph/Nodes/LimiterNode.cssrc/Beutl.Engine/Audio/Graph/Nodes/MixerNode.cssrc/Beutl.Engine/Audio/Graph/Nodes/SourceNode.cstests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs
✅ Files skipped from review due to trivial changes (1)
- src/Beutl.Engine/Audio/Graph/Nodes/EqualizerNode.cs
🚧 Files skipped from review as they are similar to previous changes (1)
- src/Beutl.Engine/Audio/Graph/Nodes/ClipNode.cs
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5c0f588207
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
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
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
ac81c39 to
b5c71df
Compare
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
|
Caution Failed to replace (edit) comment. This is likely due to insufficient permissions or the comment being deleted. Error details |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 583146a1e9
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
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.
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs (2)
153-182: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick winThis test passes trivially and does not exercise
AppendFlushedTail.The window
Context(TimeSpan.Zero, clipSamples)covers the clip exactly, sonewBuffer.SampleCount == clipSamples,writeOffset == clipSamples, andcapacity == 0—AppendFlushedTailreturns early without draining (the samecapacity == 0situation the nested-clip test explicitly documents at Lines 288-289). The asserted indices[clipSamples - L, clipSamples)fall inside the main delayed-limiter slice, which is real non-zero sine regardless of recovery, so the assertion holds even if tail recovery is a complete no-op. To actually validate the recovery path, extend the window past the clip end so trailing capacity exists and assert on[clipSamples, clipSamples + L).💚 Proposed fix to exercise the drain path
- // 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++) + // A window that extends L samples past the clip end leaves trailing room, so AppendFlushedTail + // drains the limiter's held tail into samples [clipSamples, clipSamples + L). + using var output = clip.Process(Context(TimeSpan.Zero, clipSamples + L)); + + var data = output.GetChannelData(0); + bool tailNonZero = false; + for (int i = clipSamples; i < clipSamples + L; i++) { if (MathF.Abs(data[i]) > 1e-5f) { tailNonZero = true; break; } }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs` around lines 153 - 182, The test ClipNode_TerminalWindow_AppendsRecoveredTail does not actually exercise the AppendFlushedTail recovery path because the Context window exactly covers the clip with no trailing capacity, causing AppendFlushedTail to return early. To fix this, extend the Context window past the clip end to create trailing capacity (so the window sample count exceeds clipSamples), and then update the assertion loop indices to check the newly recovered tail samples in the range beyond the original clip end (approximately clipSamples to clipSamples + L) instead of checking indices within the clip that already contain valid sine data from the delayed limiter output.
185-208: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
ClipNode_ZeroLookahead_IsNoOpis tautological — it can never fail.
clipA/clipBuse identical configurations and the identicalContext(TimeSpan.Zero, clipSamples), andRangeSineNodeis deterministic, soa[i] == b[i]always holds regardless of behavior. The comment claims the reference is "A mid-clip window (does not reach the end)," butclipBuses the same terminal window asclipA. Either make the reference genuinely bypass the drain (e.g., a window that stops short of the clip end, comparing the overlapping region) or compare against the raw source output so the assertion has discriminating power.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs` around lines 185 - 208, The test ClipNode_ZeroLookahead_IsNoOp is tautological because clipA and clipB use identical configurations and both process with Context(TimeSpan.Zero, clipSamples), so a[i] will always equal b[i] regardless of the drain behavior. Fix this by modifying the reference (clipB) to use a different window that genuinely represents a mid-clip region that does not reach the end, such as Context(TimeSpan.Zero, clipSamples - someOffset), so the overlapping region comparison can actually detect if the drain perturbs samples, or alternatively compare against the raw sourceB output directly instead of a processed clipB output.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@tests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs`:
- Around line 153-182: The test ClipNode_TerminalWindow_AppendsRecoveredTail
does not actually exercise the AppendFlushedTail recovery path because the
Context window exactly covers the clip with no trailing capacity, causing
AppendFlushedTail to return early. To fix this, extend the Context window past
the clip end to create trailing capacity (so the window sample count exceeds
clipSamples), and then update the assertion loop indices to check the newly
recovered tail samples in the range beyond the original clip end (approximately
clipSamples to clipSamples + L) instead of checking indices within the clip that
already contain valid sine data from the delayed limiter output.
- Around line 185-208: The test ClipNode_ZeroLookahead_IsNoOp is tautological
because clipA and clipB use identical configurations and both process with
Context(TimeSpan.Zero, clipSamples), so a[i] will always equal b[i] regardless
of the drain behavior. Fix this by modifying the reference (clipB) to use a
different window that genuinely represents a mid-clip region that does not reach
the end, such as Context(TimeSpan.Zero, clipSamples - someOffset), so the
overlapping region comparison can actually detect if the drain perturbs samples,
or alternatively compare against the raw sourceB output directly instead of a
processed clipB output.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: d48db3cc-c99a-4a33-a44d-1f11c1033a3a
📒 Files selected for processing (5)
src/Beutl.Engine/Audio/Graph/AudioNode.cssrc/Beutl.Engine/Audio/Graph/Nodes/ClipNode.cssrc/Beutl.Engine/Audio/Graph/Nodes/DelayNode.cssrc/Beutl.Engine/Audio/Graph/Nodes/LimiterNode.cstests/Beutl.UnitTests/Engine/Audio/AudioLatencyCompensationTests.cs
🚧 Files skipped from review as they are similar to previous changes (3)
- src/Beutl.Engine/Audio/Graph/Nodes/DelayNode.cs
- src/Beutl.Engine/Audio/Graph/Nodes/LimiterNode.cs
- src/Beutl.Engine/Audio/Graph/AudioNode.cs
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3ff93c5e26
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
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).
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f054212294
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
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.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ef513eb5f9
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
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.
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.
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.
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.
|
No TODO comments were found. |
Minimum allowed line rate is |
Description
Implements board #77 end to end: a latency reporting API plus tail-loss compensation for lookahead-bearing audio effects (the Limiter). Lookahead delays the signal by
lookaheadSamples; becauseComposerallocates exactlyceil(Duration*rate)samples and never reads past the clip, the clip lost that many samples off its tail (stuck in the delay line). This recovers them.Reporting
AudioNode.GetLatencySamples(int sampleRate)(virtual, default0) reports a node's own latency;GetTotalLatencySamplesaggregates the feeding path aslocal + max(input totals)— a linear cascade sums, a mixer fan-in takes the slowest branch. Both virtual/overridable.AudioEffect.GetLatencySampleslets a host query latency before a graph node exists;AudioEffectGroupsums enabled children.LimiterNode/LimiterEffectoverride viaLimiterParameters.ToLatencySamples. All entry points guardsampleRate > 0.Compensation (node
Flushcontract)AudioNode.Flush(AudioProcessContext): a node drains the latency it still holds as the block that follows the clip's lastProcessoutput. The real source is treated as exhausted — a leaf node returns silence — so a trimmed clip never bleeds real audio. A single-input node runs its upstream drain through its ownProcessTail, so downstream effects still shape the tail; a fan-in node (MixerNode) overridesFlushto drain and mix every branch; the base throws for an un-overridden multi-input node.ClipNode, on the window that reaches the clip's true end (newRange.End == range.End), callsInputs[0].Flushover clip-local[Duration, Duration+L)— contiguous with the main slice, so the cached effect never resets — and appends the drained tail into the trailing pad the window already reserves, clamped to capacity.ClipNode.Flushremaps a parent-domain drain back to clip-local[Duration, …)so a nested clip (a SoundGroup child mixed under a group clip) keeps its cached limiter contiguous instead of resetting and dropping the tail. The parent's start is intentionally dropped, which is also why an interveningShiftNodeneeds no flush override.Why a node contract, not range expansion
A design pass (3 approaches × 3 adversarial verifiers) confirmed the board's "expand the Composer range + shift output" is incompatible with this stateful chunked pull graph: expanding each window pushes consecutive preview windows outside
LimiterNode's 1-tick contiguity tolerance, triggering a spuriousReset()+ double-processed overlap at every chunk boundary. Compensation therefore lives in a nodeFlushcontract that keeps externally-observedTimeRangeexactly tiling. Mid-clip windows stay byte-identical to before.Scope: tail recovery only. Leading lookahead silence is intentionally left intact — removing it would shift the clip and break A/V sync (documented-intended
LimiterEffectbehavior).Affected areas
Beutl.Engine(rendering / scene / track)Beutl.ProjectSystem/ UI /Beutl.Extensibility/Beutl.NodeGraph/Beutl.FFmpegIpc/Beutl.Api/ Build-CI-docsBreaking changes
None. All additions are additive (new virtual members with defaults, new overrides on sealed/partial classes); existing subclasses inherit the defaults unchanged.
Processoutput is byte-identical except the clip's terminal window, which now carries the recovered tail in samples that were previously silent padding.Test plan
Two NUnit fixtures under
tests/Beutl.UnitTests/Engine/Audio/:AudioLatencyTests: reporting — lookahead at 48k/96k, queryable before Process, matches the actually-measured impulse delay (incl. the 20 ms clamp boundary), pass-through/zero effects, group sum, cascade sum vs mixer max, uniform non-positive-rate throw.AudioLatencyCompensationTests:Flushrecovers the exact tail samples;Process+Flushconcatenate to the full delayed input with no loss; a latency-free chain flushes to silence;Flushstays contiguous (real audio, not post-reset silence); downstream processing is applied to the drained tail;ClipNodeterminal window appends the recovered tail; a nestedClipNodeflushed in a parent's time domain remaps to clip-local and still recovers the tail; a nested clip trimmed by its parent flushes from its last processed local time (not its natural Duration); a nested flush continues after a partial tail append instead of stepping backward;MixerNodemerges branch tails; a bare multi-inputFlushthrows; animated lookahead reports the worst-case latency;L==0is a no-op.Verified locally:
dotnet buildEngine green;dotnet testEngine.Audio latency suites 34 passed / 0 failed;dotnet format --verify-no-changesclean on touched files.Follow-ups
ClipNode.AppendFlushedTail): when a compose block ends exactly at the clip boundary the recovered tail has nowhere to go and the next block excludes the clip (half-openTimeRange.Intersects), so a few ms of lookahead tail is lost on that alignment. Fixing it needs either an oversized terminalClipNodeoutput (breaks the output-length contract) or a compositor that keeps a latency-bearing clip live for one trailing block — both out of scope here.LimiterNode.ProcessTail): on the flush path the animatedLookaheadis sampled over the post-clip drain range, not the clip samples that were actually delayed. An automation that holds a high lookahead through the clip end but keyframes back to 0 atDurationreadsRead(0)(just-written silence) and still drops the tail even thoughGetLatencySamplesreserved the worst case. Draining at the retaining lookahead is a follow-up.AudioNode.RecordProcessedChannelCount): a custom zero-input leaf that omitsRecordProcessedChannelCountflushes stereo silence regardless of its real mono/multichannel layout, which can make a downstream limiter reinitialize and drop its tail. Built-inSourceNoderecords correctly; making the base learn the emitted layout is a follow-up.DelayNode):DelayNodedeliberately reports0latency — an echo/delay is an intentional, audible delay (render-tail-time territory), not processing latency that PDC compensates — so a wet-only delay placed after a limiter gets no extra flush budget and its delayed tail can still clip at the clip boundary. Recovering it is a separate render-tail-time concern, not latency reporting.Flush: onlyLimiterNodeshapes a held tail today (the others use the pass-throughProcessTaildefault). ADelayNodeecho tail orEqualizerNodeIIR ring-out would need their own tail handling. TheClipNodedrain is already capacity-bounded so a large reported cascade latency cannot overflow.MixerNode.Flush): the fan-in flush drains every branch unconditionally. A branch whose child clip ended before the group's terminal slice — and whose own terminal block landed exactly on its clip boundary (the chunk-alignment edge above) — still holds a stale tail that is emitted into the group pad seconds late. Draining only branches live through the terminal slice needs per-branch clip-liveness the mixer does not track today.ResampleNode):Processfeeds its input aSourceSampleRatecontext and resamples to the output rate, but there is noFlushoverride, so the inherited flush forwards the output-rate context upstream and does not resample the drained tail. A latency node placed upstream of the resampler would see a rate change on flush, reinitialize, and drop its tail. The built-in Sound chain resamples before the effects (so a limiter is always downstream at the output rate); this only bites a custom graph that resamples after a latency-bearing node. A rate-mirroringFlushoverride is the follow-up.SceneSound.SceneNode): theSceneNodeleaf composes a referenced scene through its ownComposerbut reports the zero-latency default, so an outerClipNodereserves no drain for a lookahead limiter inside the referenced scene; aSceneSoundclip that cuts the scene before an inner limiter-bearing sound ends drops that inner tail. Surfacing it needsSceneNodeto aggregate its internal graph's latency and flush that graph.Fixed issues / References
https://claude.ai/code/session_01PJ9eDL52jpiM1t5oDRCLM2
Summary by CodeRabbit