loop: crossfade the Gate sustain seam, and unshadow the loop handles that made loop points look gone

This commit is contained in:
2026-07-31 17:36:36 -04:00
parent a90ccd9a00
commit 0fe4166d7d
25 changed files with 991 additions and 64 deletions
+34 -11
View File
@@ -52,20 +52,33 @@ bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) {
}
}
const SetupMarkers m = pickedMarkers(frames);
// The crossfade handle first, and only when there IS a loop to fade: at a zero fade it
// sits exactly on the loop start, so it can only stay reachable by owning the top strip
// (waveform_view.h's handle-vs-column split) and being asked first.
if (m.hasLoop &&
contains(markerHandleRect(overlay, frames, m.loopStart - m.crossfade), x, y)) {
beginMarkerDrag(WaveMarker::kLoopXfade, m, frames, x);
return true;
}
const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd};
const int hit = markerAtPoint(overlay, frames, markerFrames, 3, x, y);
if (hit >= 0) {
drag_ = DragKind::kWaveMarker;
waveMarker_ = static_cast<WaveMarker>(hit);
dragStartX_ = x;
dragStartMarkers_ = m;
dragSampleFrames_ = frames;
dragStartParams_ = params_;
beginMarkerDrag(static_cast<WaveMarker>(hit), m, frames, x);
return true;
}
return false;
}
void ReaSamplerEditor::beginMarkerDrag(WaveMarker which, const SetupMarkers& m,
std::int64_t frames, int x) {
drag_ = DragKind::kWaveMarker;
waveMarker_ = which;
dragStartX_ = x;
dragStartMarkers_ = m;
dragSampleFrames_ = frames;
dragStartParams_ = params_;
}
void ReaSamplerEditor::dragWaveform(const FaceLayout& fl, int x, int y) {
const OverlayArea overlay = waveformOverlayArea(fl.bands.waveform);
const int dx = x - dragStartX_;
@@ -96,14 +109,17 @@ void ReaSamplerEditor::dragWaveform(const FaceLayout& fl, int x, int y) {
// Grabbed frame at grab time, from the snapshot (so the delta is measured from grab).
const int idx = static_cast<int>(waveMarker_);
const std::int64_t startVals[3] = {dragStartMarkers_.start, dragStartMarkers_.loopStart,
dragStartMarkers_.loopEnd};
const std::int64_t startVals[4] = {dragStartMarkers_.start, dragStartMarkers_.loopStart,
dragStartMarkers_.loopEnd,
dragStartMarkers_.loopStart -
dragStartMarkers_.crossfade};
std::int64_t newFrame = resolveDragFrame(overlay, frames, startVals[idx], dx);
// Snap to the nearest zero crossing in the decoded PCM. Pure over the cached mono
// frames — no host types, no file I/O.
// frames — no host types, no file I/O. The crossfade handle is exempt: it sets a fade
// LENGTH, and the whole point of the fade is that its edges need no zero crossing.
const std::vector<AudioSample>& pcm = monoPcmFor(selectedId_);
if (!pcm.empty()) {
if (!pcm.empty() && waveMarker_ != WaveMarker::kLoopXfade) {
newFrame = nearestZeroCrossing(pcm.data(), static_cast<std::int64_t>(pcm.size()),
newFrame);
}
@@ -116,12 +132,19 @@ void ReaSamplerEditor::dragWaveform(const FaceLayout& fl, int x, int y) {
} else if (waveMarker_ == WaveMarker::kLoopStart) {
m.loopStart = (std::min)(newFrame, m.loopEnd);
m.hasLoop = true;
} else { // kLoopEnd
} else if (waveMarker_ == WaveMarker::kLoopEnd) {
m.loopEnd = (std::max)(newFrame, m.loopStart);
m.hasLoop = true;
} else { // kLoopXfade — the handle sits at loopStart - crossfade, so left lengthens it
m.crossfade = (std::max)(std::int64_t{0}, m.loopStart - newFrame);
}
if (m.start < 0) m.start = 0;
if (m.start > frames - 1) m.start = frames - 1;
// Mirror resolveLoop's own bound so the handle can't be dragged somewhere the engine
// would silently clamp back: the fade reads the material ahead of the loop, and cannot
// outrun either that material or the loop itself.
m.crossfade = (std::min)(m.crossfade, (std::min)(m.loopStart, m.loopEnd - m.loopStart));
if (m.crossfade < 0) m.crossfade = 0;
applyMarkers(m);
invalidate(); // live feedback; the commit lands on release
@@ -98,6 +98,17 @@ void ReaSamplerEditor::paintWaveform(LICE_IBitmap* bmp, const Rect& band) {
static_cast<float>(kLoopSpanFillAlpha), 0);
}
}
// The crossfade region, at half the loop span's weight so the two read as nested rather
// than as a second loop. Drawn before the marker bars so the bars stay on top.
if (m.hasLoop && m.crossfade > 0) {
const int fx = frameToX(overlay, frames, m.loopStart - m.crossfade);
const int lx = frameToX(overlay, frames, m.loopStart);
if (lx > fx) {
LICE_FillRect(bmp, fx, overlayRect.y, lx - fx, overlayRect.height,
toLice(roleColor(kRoleLoopMarker)),
static_cast<float>(kLoopSpanFillAlpha) * 0.5f, 0);
}
}
const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd};
const Role markerRoles[3] = {kRoleStartMarker, kRoleLoopMarker, kRoleLoopMarker};
for (int i = 0; i < 3; ++i) {
@@ -107,6 +118,15 @@ void ReaSamplerEditor::paintWaveform(LICE_IBitmap* bmp, const Rect& band) {
LICE_FillRect(bmp, mx - 1, overlayRect.y, 2, overlayRect.height,
toLice(roleColor(markerRoles[i])), alpha, 0);
}
// The crossfade's grab tab. Only offered with a loop set, matching the hit-test, and it
// is the whole affordance for a zero-length fade — nothing else marks where it sits.
if (m.hasLoop) {
const Rect tab = markerHandleRect(overlay, frames, m.loopStart - m.crossfade);
if (!tab.empty()) {
LICE_FillRect(bmp, tab.x, tab.y, tab.width, tab.height,
toLice(roleColor(kRoleLoopMarker)), 1.0f, 0);
}
}
paintEnvelopeOverlay(bmp, overlay, frames);
}
+21 -6
View File
@@ -17,6 +17,7 @@
#include "core/ui/bank_grid.h" // ThumbnailKey / thumbnailKeyString (the pure key)
#include "core/util/file_bytes.h" // shared whole-file loader
#include "ext_keys.h"
#include "core/instrument/engine/loop/loop_span.h" // defaultLoopBounds (the shared ghost span)
#include "core/instrument/ui/browser_scroll.h" // nameMatchesQuery (type-to-filter)
#include "shell/instrument/reaper_bridge.h"
#include "shell/instrument/reasampler_processor.h"
@@ -31,6 +32,8 @@ using capture::WavLayout;
using capture::extractFloatFrames;
using capture::parseWavLayout;
using capture::resolveBankFile;
using instrument::engine::loop::LoopBounds;
using instrument::engine::loop::defaultLoopBounds;
using instrument::ui::nameMatchesQuery;
using ui::ThumbnailKey;
using ui::thumbnailKeyString;
@@ -160,10 +163,12 @@ void ReaSamplerEditor::loadSelection(const std::string& id) {
// loaded, so a load swaps the sound and keeps the settings. The three CAPTURE-ANCHORED
// overrides are: a root, a loop span and a start frame all name positions in the
// OUTGOING capture and mean nothing in the new one, so they clear and the new capture
// plays from its own bank intrinsics.
// plays from its own bank intrinsics. The loop crossfade goes with the span it belongs
// to — it is a length in the outgoing capture's frames.
selectedId_ = id;
params_.rootOverride.reset();
params_.loopOverride.reset();
params_.loopCrossfadeFrames = 0;
params_.startPoint.reset();
commitAndReload();
}
@@ -196,10 +201,17 @@ ReaSamplerEditor::SetupMarkers ReaSamplerEditor::pickedMarkers(std::int64_t fram
m.loopEnd = params_.loopOverride->end;
}
if (params_.startPoint) m.start = *params_.startPoint;
// Default an unset loop's end to the sample length so the loop markers have somewhere sane
// to sit before the user drags (loopStart stays 0). The "no loop" state is m.hasLoop==false;
// the markers are still drawn (drag one to CREATE a loop).
if (!m.hasLoop && m.loopEnd == 0) m.loopEnd = frames > 0 ? frames : 0;
m.crossfade = params_.loopCrossfadeFrames;
// A collapsed or inverted span is the OFF state (the engine refuses it either way), so
// park the handles on the shared default rather than leaving them stacked on each other
// where neither could be grabbed apart again. The markers are still drawn at 'no loop'
// weight — drag one to CREATE a loop.
if (!m.hasLoop || m.loopEnd <= m.loopStart) {
m.hasLoop = false;
const LoopBounds d = defaultLoopBounds(frames);
m.loopStart = d.start;
m.loopEnd = d.end;
}
return m;
}
@@ -207,10 +219,13 @@ void ReaSamplerEditor::applyMarkers(const SetupMarkers& m) {
// Write the edited markers into the parameter set as the loop/start override. The bank
// intrinsic is never written (read-only bank consumer).
SampleLoop loop;
loop.hasLoop = m.hasLoop;
// Collapsing the span onto itself is the OFF gesture — record it as such so the next
// pickedMarkers re-offers the default handles instead of two coincident ones.
loop.hasLoop = m.hasLoop && m.loopEnd > m.loopStart;
loop.start = m.loopStart;
loop.end = m.loopEnd;
params_.loopOverride = loop;
params_.loopCrossfadeFrames = m.crossfade;
params_.startPoint = m.start;
}
+14 -4
View File
@@ -93,8 +93,11 @@ private:
using ParamControl = instrument::ui::DeckParam;
// The waveform markers on the waveform band: start-point + the sustain loop's two ends,
// in draw + hit order.
enum class WaveMarker { kStart = 0, kLoopStart = 1, kLoopEnd = 2, kCount = 3 };
// in draw + hit order, then the crossfade handle. The crossfade is NOT part of the
// full-height column hit-test — it answers only in its top-strip handle (waveform_view's
// markerHandleRect), because at a zero fade it sits exactly on the loop start.
enum class WaveMarker { kStart = 0, kLoopStart = 1, kLoopEnd = 2, kLoopXfade = 3,
kCount = 4 };
// The interactive element under the pointer, resolved live in WM_MOUSEMOVE. `index`
// disambiguates within a kind (tab ordinal, visible-card index, control-row id); -1 when
@@ -291,12 +294,15 @@ private:
std::string samplePathFor(const std::string& sampleId) const;
// The effective loop + start markers for the loaded capture: the parameter set's
// override when one is set, else the bank's loop intrinsic / frame 0. Absent loop ->
// loopStart==loopEnd==0. `frames` defaults loopEnd when the bank left the loop empty.
// override when one is set, else the bank's loop intrinsic / frame 0. With no loop set,
// the loop handles park on loop_span's defaultLoopBounds so both stay grabbable — the
// frame-0 default they replace put loopStart under the start marker, where nothing could
// reach it.
struct SetupMarkers {
std::int64_t start = 0;
std::int64_t loopStart = 0;
std::int64_t loopEnd = 0;
std::int64_t crossfade = 0; // pre-seam fade, SOURCE frames; handle at loopStart - this
bool hasLoop = false; // whether a sustain loop is set (drives the "no loop" affordance)
};
SetupMarkers pickedMarkers(std::int64_t frames) const;
@@ -305,6 +311,10 @@ private:
// callers decide live-drag vs final commit.
void applyMarkers(const SetupMarkers& m);
// Arms a waveform-marker drag: the grabbed marker plus the snapshot the pixel-delta
// resolver and the inter-marker clamps measure from.
void beginMarkerDrag(WaveMarker which, const SetupMarkers& m, std::int64_t frames, int x);
// Deck knobs edit the parameter set's PlaySeconds (play mode + AHDSR; pitch engine + AD
// pitch envelope) — wall-clock seconds, rate-free; the build resolves to frames.