Skip to content

Add per-clip loop-seam crossfade for launcher (session-view) playback#3

Merged
lucaromagnoli merged 6 commits into
magda_minimalfrom
feat/loop-crossfade
May 2, 2026
Merged

Add per-clip loop-seam crossfade for launcher (session-view) playback#3
lucaromagnoli merged 6 commits into
magda_minimalfrom
feat/loop-crossfade

Conversation

@lucaromagnoli

Copy link
Copy Markdown
Contributor

AudioClipBase gains a loopCrossfade TimeDuration property (default 0, backward-compatible). When non-zero on a launcher clip, the last N samples of every loop cycle are equal-power blended with the first N samples of the cycle, smoothing the seam without changing the cycle period.

Implementation lives in BeatRangeReader (the autoTempo / launcher path in WaveNodeRealTime). EditNodeBuilder converts the clip's seconds duration to project-beat duration using the edit's BPM at graph-build time and threads it through BeatConfig. The cap-to-half-loop guard prevents two adjacent crossfade regions from overlapping.

Arrangement clips and warped clips are unchanged — they don't go through BeatRangeReader's seam logic.

AudioClipBase gains a loopCrossfade TimeDuration property (default 0,
backward-compatible). When non-zero on a launcher clip, the last N
samples of every loop cycle are equal-power blended with the first N
samples of the cycle, smoothing the seam without changing the cycle
period.

Implementation lives in BeatRangeReader (the autoTempo / launcher path
in WaveNodeRealTime). EditNodeBuilder converts the clip's seconds
duration to project-beat duration using the edit's BPM at graph-build
time and threads it through BeatConfig. The cap-to-half-loop guard
prevents two adjacent crossfade regions from overlapping.

Arrangement clips and warped clips are unchanged — they don't go
through BeatRangeReader's seam logic.
// editDuration scales with the proportion of the read this region covers.
const auto regionEditDuration = editDuration * (p1 - p0);

const bool headOk = source->read ({ headStartTime, headEndTime },

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This read uses the same TimeRangeReader/resampler/time-stretcher chain as the main playback stream. Because TimeRangeReader::read seeks and mutates the downstream reader state, the loop-head read can leave the main reader primed from the head audio while the next block is treated as contiguous tail/loop playback. With the Lagrange path, for example, setPosition does not reset interpolation state, so this can leak the crossfade side-read into subsequent output. This needs an independent reader/state path, or explicit isolation/restoration/reset of the main stream state.

regionEditDuration,
isContiguous,
playbackSpeedRatio);
(void) headOk; // best effort — silence on cache miss is preferable to skipping the blend

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ignoring headOk after clearing headView means a failed/cache-miss loop-head read still applies the equal-power fade-out to the existing tail and mixes in silence. That turns a read failure into an audible seam dropout. If the side-read fails, skip the blend for this region or propagate failure so the existing read-failure fade/retry handling can engage.

: getPosition().getLength();
auto cap = TimeDuration::fromSeconds (loopLen.inSeconds() * 0.5);
length = juce::jlimit (TimeDuration(), cap, length);
loopCrossfade = length;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This setter writes the new cached value, but IDs::loopCrossfade is not handled in AudioClipBase::valueTreePropertyChanged, so changing the property may not call changed() and rebuild the launcher playback graph until some unrelated property changes. Please add it to the same property-change branch as fades/loop lengths.


fadeIn.referTo (state, IDs::fadeIn, um);
fadeOut.referTo (state, IDs::fadeOut, um);
loopCrossfade.referTo (state, IDs::loopCrossfade, um);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this adds a new persisted CachedValue, AudioClipBase::cloneFrom should also copy loopCrossfade alongside fadeIn, fadeOut, and the loop properties. Duplicated/cloned clips otherwise lose the new per-clip seam setting.

- Reset the shared reader chain after each loop-head side-read so the
  Lagrange interpolator's history buffer (and any other downstream
  state) cannot leak into the next block's main read. The next read
  re-seeks via setPosition; the reset gives it a clean interp slate.
- Skip the equal-power blend on a failed side-read instead of mixing
  silence into a faded-down tail — turns a cache miss back into a
  one-block stitch artefact rather than an audible seam dropout.
- Hook IDs::loopCrossfade in valueTreePropertyChanged so changing the
  property triggers the same playback-graph rebuild path as fadeIn /
  fadeOut and the loop properties.
- Copy loopCrossfade in cloneFrom so duplicated clips preserve the
  per-clip seam setting alongside fadeIn / fadeOut.
The TE modules are JUCE INTERFACE libraries — header-only until a
consumer pulls them in — so CI needs an actual link target to compile
the .cpp files. Adds:

- ci/compile_check.cpp: tiny console-app entry that constructs an
  Engine and exits, just to force every module .cpp to be compiled.
- CMakeLists.txt: TE_BUILD_COMPILE_CHECK option (off by default) that
  pulls compile_check.cpp into a juce_add_console_app target and links
  the three TE modules.
- .github/workflows/build.yml: matrix build on ubuntu-latest and
  macos-latest, Release config, runs on push to main/master/feat/**/
  fix/**, on every PR, and on manual dispatch. Per-ref concurrency
  cancellation so superseded runs don't queue.

Forks without CI silently miss compile breakage between MAGDA bumps —
this catches it before merge instead of at the consumer's build.
The fork's per-device gain/metering integration in EditNodeBuilder.cpp
includes "DeviceMeteringManager.hpp" — a header that lives in MAGDA,
not in this repo. MAGDA's build adds it to the include path; the
fork's standalone CI doesn't. Wrap the include with __has_include and
gate the two use sites on the resulting MAGDA_HAS_DEVICE_METERING
macro so the file builds either way.

The DeviceGainNode class itself only uses tracktion::graph types and
stays unconditional — when the manager isn't present it's just unused.
- Trigger on push to main / master / feat/** / fix/** only — PR runs
  duplicate the push CI on the same SHAs and just consume minutes.
- Split into three explicit jobs (ubuntu, macos, windows) so each can
  use its native dependency setup. The matrix-with-conditional-steps
  pattern got unwieldy once Windows needed MSVC dev-cmd.
- Windows uses windows-latest-8core to match magda-core's CI runner;
  MSVC x64 via ilammy/msvc-dev-cmd@v1.
@lucaromagnoli lucaromagnoli merged commit fa9c109 into magda_minimal May 2, 2026
3 checks passed
@lucaromagnoli lucaromagnoli deleted the feat/loop-crossfade branch May 2, 2026 06:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant