Loop: an explicit enable, four named marks with grabbable caps, and the crossfade painted where it is actually heard

hasLoop becomes user-owned with the gestures as shortcuts onto it; no format change. START uses overlay/trace, not accent/primary, which is the waveform's own fill.
This commit is contained in:
2026-08-02 05:18:53 -04:00
parent ef59265e7a
commit a7c3c7a828
26 changed files with 1190 additions and 191 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ two small identity/helper headers this directory owns outright
The pure engine/geometry core this shell wraps (`sampler_core`, `pitch_shift`,
`sample_map`, `component_state_io`, `play_params.h`, `editor_geometry`, `sample_bands`,
`sample_chrome`, `keyboard_strip`, `waveform_view`, `capture_browser`, `browser_scroll`,
`sample_chrome`, `keyboard_strip`, `waveform_view`, `loop_marks`, `capture_browser`, `browser_scroll`,
`param_slider`, `param_taper`, `trigger_seam`, `velocity_curve`, `embed_strip`, `knob_deck`,
`deck_groups`, `deck_values`, `bake_hold`, `curve_popup`, `spline_edit`, `master_gain`,
`limiter`, `meter_ballistics`, `reasampler_uid.h`) lives in `core/instrument/*` and
+1 -1
View File
@@ -86,7 +86,7 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp")
target_link_libraries(reasampler_vst PRIVATE vst3_sdk editor_geometry bridge_marshal
sampler_core sample_map component_state_io capture_paths embed_strip app_version
capture_browser keyboard_strip sample_bands sample_chrome
waveform_view bank_sync browser_scroll param_slider tooltip
waveform_view loop_marks bank_sync browser_scroll param_slider tooltip
theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit
knob_deck deck_groups deck_values curve_popup spline_edit master_gain sample_usage
limiter meter_ballistics bake_hold
+42
View File
@@ -142,6 +142,48 @@ instrument::ui::DeckEnableState ReaSamplerEditor::deckEnableState() const {
play.filterSpline.mode == EnvMode::Spline};
}
bool ReaSamplerEditor::loopControlsLive() const {
return effectivePlayMode(params_.play) == PlayMode::Gate;
}
instrument::ui::WaveMarks ReaSamplerEditor::waveMarksFor(const SetupMarkers& m) const {
using instrument::ui::WaveMark;
instrument::ui::WaveMarks w;
w.frame[static_cast<int>(WaveMark::kStart)] = m.start;
w.frame[static_cast<int>(WaveMark::kLoopStart)] = m.loopStart;
w.frame[static_cast<int>(WaveMark::kLoopEnd)] = m.loopEnd;
// The crossfade grows LEFT from the seam it closes, which is where it is audible.
w.frame[static_cast<int>(WaveMark::kCrossfade)] = m.loopEnd - m.crossfade;
// The crossfade mark belongs to an ACTIVE loop: with the enable off there is no seam for it
// to sit on, and no length to drag.
w.present[static_cast<int>(WaveMark::kStart)] = true;
w.present[static_cast<int>(WaveMark::kLoopStart)] = true;
w.present[static_cast<int>(WaveMark::kLoopEnd)] = true;
w.present[static_cast<int>(WaveMark::kCrossfade)] = m.hasLoop;
return w;
}
instrument::ui::WaveMarks ReaSamplerEditor::grabbableMarks(const SetupMarkers& m) const {
using instrument::ui::WaveMark;
instrument::ui::WaveMarks w = waveMarksFor(m);
if (!loopControlsLive()) {
w.present[static_cast<int>(WaveMark::kLoopStart)] = false;
w.present[static_cast<int>(WaveMark::kLoopEnd)] = false;
w.present[static_cast<int>(WaveMark::kCrossfade)] = false;
}
return w;
}
void ReaSamplerEditor::setLoopEnabled(bool on) {
const auto frames = static_cast<std::int64_t>(monoPcmFor(selectedId_).size());
if (frames <= 0) return;
SetupMarkers m = pickedMarkers(frames);
if (m.hasLoop == on) return; // a no-op commit would buy a re-decode for nothing
m.hasLoop = on;
applyMarkers(m);
commitAndReload();
}
void ReaSamplerEditor::applyDeckKnob(int id, double norm) {
if (!processor_) return;
norm = clamp01(norm);
+1
View File
@@ -170,6 +170,7 @@ void ReaSamplerEditor::resolveHover(int x, int y) {
const FaceLayout fl = faceLayout(w, hgt);
h = hoverChrome(fl, x, y);
if (h.kind == HoverKind::kNone && !selectedId_.empty()) h = hoverDeck(fl, x, y);
if (h.kind == HoverKind::kNone && !selectedId_.empty()) h = hoverWaveform(fl, x, y);
}
if (h != hover_) {
+21 -2
View File
@@ -1,6 +1,6 @@
// editor_input_chrome.cpp — the CHROME band's input: the Browse nav, the preview trigger,
// the preview-velocity knob grab, the channel toggle, and the piano strip's root grab plus
// its live drag. Windows-only.
// the preview-velocity knob grab, the loop enable, the channel toggle, and the piano strip's
// root grab plus its live drag. Windows-only.
#include "shell/instrument/reasampler_editor.h"
@@ -73,6 +73,21 @@ bool ReaSamplerEditor::mouseDownChrome(const FaceLayout& fl, int x, int y) {
invalidate();
return true;
}
// The loop enable. Inert (not hidden) outside Gate: that refusal comes from the engine and
// no click can talk it out of it — unlike the user's own off, which the marks themselves
// still offer to reverse.
if (loopControlsLive()) {
if (contains(cr.loopOff, x, y)) {
setLoopEnabled(false);
invalidate();
return true;
}
if (contains(cr.loopOn, x, y)) {
setLoopEnabled(true);
invalidate();
return true;
}
}
if (contains(cr.chanMono, x, y)) {
channelMode_ = ChannelMode::Mono;
processor_->setChannelMode(ChannelMode::Mono);
@@ -146,6 +161,10 @@ ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverChrome(const FaceLayout& fl
if (contains(cr.bake, x, y)) return {HoverKind::kBake, -1};
if (contains(cr.preview, x, y)) return {HoverKind::kPreview, -1};
if (contains(cr.velCell, x, y)) return {HoverKind::kVelKnob, -1};
if (loopControlsLive()) {
if (contains(cr.loopOff, x, y)) return {HoverKind::kLoopOff, -1};
if (contains(cr.loopOn, x, y)) return {HoverKind::kLoopOn, -1};
}
if (contains(cr.chanMono, x, y)) return {HoverKind::kChanMono, -1};
if (contains(cr.chanStereo, x, y)) return {HoverKind::kChanStereo, -1};
if (!cr.rootStrip.empty()) {
+53 -20
View File
@@ -55,16 +55,20 @@ bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) {
}
const SetupMarkers m = pickedMarkers(frames);
const WaveMarks grabbable = grabbableMarks(m);
const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd};
// Three affordances can claim the same pixel: a node (the staged envelope's or the drawn
// contour's — a small fixed pick box either way), the crossfade tab (a small clipped
// top-strip tab), and a marker's full-height grab column (waveform_view.h's tab-vs-column
// split already keeps the tab apart from ITS OWN column; this is the cross-affordance case
// on top of that). resolveWaveformClaim (spline_edit.h) is the ONE arbitration: it measures
// each claimant's own NOMINAL target area and lets the smallest hit win, since a fixed check
// order shadows whichever one loses the tie — this seam regressed twice from exactly that
// fix. Never add here (kAdd is only tried once nothing else has claimed the click, below).
// contour's — a small fixed pick box either way), a mark's CAP (a small clipped top-strip
// tab), and a mark's full-height grab column (waveform_view.h's cap-vs-column split already
// keeps a cap apart from ITS OWN column; this is the cross-affordance case on top of that).
// resolveWaveformClaim (spline_edit.h) is the ONE arbitration: it measures each claimant's
// own NOMINAL target area and lets the smallest hit win, since a fixed check order shadows
// whichever one loses the tie — this seam regressed twice from exactly that fix. Giving
// every mark a cap changed WHICH mark the cap slot resolves to, not the slot's nominal area
// (every cap is one markerHandleRect) and not the ordering cap < node < column, so the
// arbitration itself is unchanged. Never add here (kAdd is only tried once nothing else has
// claimed the click, below).
WaveformClaim node;
if (envNodeHit.hit) {
constexpr std::int64_t side = 2 * kNodeGrabRadius + 1;
@@ -81,18 +85,22 @@ bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) {
}
}
const Rect tabRect =
m.hasLoop ? markerHandleRect(overlay, frames, m.loopStart - m.crossfade) : Rect{};
const WaveformClaim tab = (m.hasLoop && contains(tabRect, x, y))
? WaveformClaim{true, static_cast<std::int64_t>(tabRect.width) *
tabRect.height}
: WaveformClaim{};
const int capHit = capAtPoint(overlay, frames, grabbable, x, y);
const WaveformClaim tab =
(capHit >= 0)
? WaveformClaim{true, static_cast<std::int64_t>(2 * kMarkerHandleHalfWidth + 1) *
kMarkerHandleHeight}
: WaveformClaim{};
// Nominal, not actual: markerAtPoint clips the column at the overlay edges (a marker at
// frame 0 has 6 usable columns, not 11) and the node's fixed side clips too at a pick-box
// corner. Both overestimate in the direction that already produces the intended winner, so
// the arbitration runs on NOMINAL area, not the measured hit-testable pixel count.
const int markerHit = markerAtPoint(overlay, frames, markerFrames, 3, x, y);
const int markerHit =
(grabbable.present[static_cast<int>(WaveMark::kLoopStart)]
? markerAtPoint(overlay, frames, markerFrames, 3, x, y)
// In Trigger only START answers a column, and it is index 0 of the same array.
: markerAtPoint(overlay, frames, markerFrames, 1, x, y));
const WaveformClaim marker =
(markerHit >= 0)
? WaveformClaim{true, static_cast<std::int64_t>(2 * kMarkerGrabWidth + 1) *
@@ -114,7 +122,7 @@ bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) {
}
return splineOverlayClick(overlay, x, y, gesture, /*addOnEmptySpace=*/false);
case WaveformClaimant::kTab:
beginMarkerDrag(WaveMarker::kLoopXfade, m, frames, x);
beginMarkerDrag(static_cast<WaveMarker>(capHit), m, frames, x);
return true;
case WaveformClaimant::kMarker:
beginMarkerDrag(static_cast<WaveMarker>(markerHit), m, frames, x);
@@ -179,6 +187,28 @@ bool ReaSamplerEditor::splineOverlayClick(const OverlayArea& waveArea, int x, in
return true;
}
ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverWaveform(const FaceLayout& fl, int x,
int y) {
// Caps only: the cap is the grip, so it is the one thing on the overlay a resting pointer
// can be "on". A hovered mark promotes its own label past the suppression rule.
//
// Rejected on the band's own rect FIRST, before anything expensive: this runs on every
// WM_MOUSEMOVE, and with no loop override set pickedMarkers costs a bridge read plus a bank
// parse. Every cap lives in the top kMarkerHandleHeight of the band, so that strip is the
// only place the answer can be anything but a miss.
const Rect& band = fl.bands.waveform;
if (band.empty() || x < band.x || x >= band.right() || y < band.y ||
y >= band.y + kMarkerHandleHeight) {
return {};
}
const auto frames = static_cast<std::int64_t>(monoPcmFor(selectedId_).size());
if (frames <= 0) return {};
const OverlayArea overlay = waveformOverlayArea(band);
const int cap = capAtPoint(overlay, frames, grabbableMarks(pickedMarkers(frames)), x, y);
if (cap < 0) return {};
return {HoverKind::kWaveMark, cap};
}
void ReaSamplerEditor::beginMarkerDrag(WaveMarker which, const SetupMarkers& m,
std::int64_t frames, int x) {
drag_ = DragKind::kWaveMarker;
@@ -243,7 +273,7 @@ void ReaSamplerEditor::dragWaveform(const FaceLayout& fl, int x, int y) {
const int idx = static_cast<int>(waveMarker_);
const std::int64_t startVals[4] = {dragStartMarkers_.start, dragStartMarkers_.loopStart,
dragStartMarkers_.loopEnd,
dragStartMarkers_.loopStart -
dragStartMarkers_.loopEnd -
dragStartMarkers_.crossfade};
std::int64_t newFrame = resolveDragFrame(overlay, frames, startVals[idx], dx);
@@ -251,13 +281,16 @@ void ReaSamplerEditor::dragWaveform(const FaceLayout& fl, int x, int y) {
// 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() && waveMarker_ != WaveMarker::kLoopXfade) {
if (!pcm.empty() && waveMarker_ != WaveMarker::kCrossfade) {
newFrame = nearestZeroCrossing(pcm.data(), static_cast<std::int64_t>(pcm.size()),
newFrame);
}
// Build the edited marker set from the snapshot, moving only the grabbed marker, then
// clamp: loopStart <= loopEnd, start in [0, frames-1]. Dragging a loop marker MAKES a loop.
// clamp: loopStart <= loopEnd, start in [0, frames-1]. Dragging a LOOP marker turns the
// enable on — a grab implies intent to loop, and it is what teaches the chrome toggle by
// demonstration. Dragging START does not: it is live in both modes and says nothing about
// the loop.
SetupMarkers m = dragStartMarkers_;
if (waveMarker_ == WaveMarker::kStart) {
m.start = newFrame;
@@ -267,8 +300,8 @@ void ReaSamplerEditor::dragWaveform(const FaceLayout& fl, int x, int y) {
} 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);
} else { // kCrossfade — the handle sits at loopEnd - crossfade, so left still lengthens it
m.crossfade = (std::max)(std::int64_t{0}, m.loopEnd - newFrame);
}
if (m.start < 0) m.start = 0;
if (m.start > frames - 1) m.start = frames - 1;
+9 -2
View File
@@ -55,7 +55,14 @@ void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) {
const FaceLayout fl = faceLayout(w, h);
const bool empty = selectedId_.empty();
paintChrome(bmp, fl, empty);
// Resolved ONCE per paint and handed to both bands that show it. The chrome enable and the
// waveform marks are two views of the same `hasLoop`, so they must not resolve it
// separately — and with no loop override set the resolve costs a bridge read plus a bank
// parse, which is not a cost to pay twice a frame.
SetupMarkers marks;
if (!empty) marks = pickedMarkers(static_cast<std::int64_t>(monoPcmFor(selectedId_).size()));
paintChrome(bmp, fl, empty, marks);
// Nothing loaded: the lower bands carry the "pick a capture" prompt pointing at Browse
// (which the chrome lit above), and there is nothing to deck.
@@ -66,7 +73,7 @@ void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) {
return;
}
paintWaveform(bmp, fl.bands.waveform);
paintWaveform(bmp, fl.bands.waveform, marks);
paintDeck(bmp, fl);
// The curve popup: a centered sheet over the whole face, drawn last.
+28 -4
View File
@@ -1,7 +1,7 @@
// editor_paint_chrome.cpp — the CHROME band's painter: the toolbar row (product title +
// live readout, then the control run — preview, preview-velocity knob, Mono|Stereo, Browse)
// over the strip row, which the piano strip has to itself. Windows-only; all rects come from
// the pure sample_chrome interior and the pure keyboard_strip geometry.
// live readout, then the control run — preview, preview-velocity knob, Loop Off|On,
// Mono|Stereo, Browse) over the strip row, which the piano strip has to itself. Windows-only;
// all rects come from the pure sample_chrome interior and the pure keyboard_strip geometry.
#include "shell/instrument/reasampler_editor.h"
@@ -94,7 +94,8 @@ void drawRootKey(LICE_IBitmap* bmp, const Rect& area, const StripLayout& sl, int
} // namespace
void ReaSamplerEditor::paintChrome(LICE_IBitmap* bmp, const FaceLayout& fl, bool empty) {
void ReaSamplerEditor::paintChrome(LICE_IBitmap* bmp, const FaceLayout& fl, bool empty,
const SetupMarkers& marks) {
const ChromeRects& cr = fl.chrome;
fillSurface(bmp, toKitBox(cr.toolbar), Role::BgPanel, InteractionState::Rest);
@@ -211,6 +212,29 @@ void ReaSamplerEditor::paintChrome(LICE_IBitmap* bmp, const FaceLayout& fl, bool
}
}
// Loop Off | On. A two-segment toggle in the same primitive as Mono|Stereo because it is
// the same class of control: a playback mode of the loaded capture. Outside Gate both
// segments draw Disabled and neither accepts a click — the state is preserved, not cleared,
// so the return to Gate restores it.
{
const bool live = loopControlsLive();
const bool on = marks.hasLoop;
const auto segState = [&](bool active, HoverKind hk) {
if (!live) return InteractionState::Disabled;
if (active) return InteractionState::Active;
return isHovered(hk, -1) ? InteractionState::Hover : InteractionState::Rest;
};
const InteractionState offState = segState(!on, HoverKind::kLoopOff);
const InteractionState onState = segState(on, HoverKind::kLoopOn);
fillSurface(bmp, toKitBox(cr.loopOff), Role::BgCell, offState);
fillSurface(bmp, toKitBox(cr.loopOn), Role::BgCell, onState);
const Role dim = live ? Role::TextPrimary : Role::TextDim;
kitTextCentered(bmp, cr.loopOff, "Loop Off", kToolbarFont,
(live && !on) ? Role::BgBase : dim);
kitTextCentered(bmp, cr.loopOn, "Loop On", kToolbarFont,
(live && on) ? Role::BgBase : dim);
}
// Mono | Stereo output-mode toggle.
{
const bool isStereo = (channelMode_ == ChannelMode::Stereo);
+198 -45
View File
@@ -1,5 +1,6 @@
// editor_paint_waveform.cpp — the WAVEFORM band's painter: the channel lane(s), the loop
// span + start/loop markers, and the amp-envelope overlay. Windows-only.
// editor_paint_waveform.cpp — the WAVEFORM band's painter: the channel lane(s), the loop span,
// the four marks (line + shaped cap + label) with the crossfade's wedge and ghost, and the
// envelope overlay. Windows-only. All cap/label geometry is the pure waveform_view module's.
//
// Overlay contract: see waveform_view.h's WaveformSurface.
@@ -26,16 +27,41 @@ using namespace reasampler::instrument::ui; // lanes + waveform geometry
using audio::computeEnvelope;
namespace {
// Marker roles — semantic, drawn through the kit's palette: start AND loop start/end both
// = teal (secondary). Markers are 2px bars and a translucent span fill, not the 1px trace, so
// they live with 1.92:1 against the waveform; the trace, which cannot, has its own role.
// Do not collapse the two back onto one role — they overlap in this rect. The trace crossing
// the loop-span fill is a KNOWN, ACCEPTED under-floor pair (2.25:1 against a 3:1 floor), and no
// trace value fixes it — see the two-neighbour rule in core/ui/CLAUDE.md. If it is ever
// resolved, the FILL is what changes; do not nudge a color to chase it.
constexpr Role kRoleStartMarker = Role::AccentSecondary;
// Marker roles — semantic, drawn through the kit's palette. The loop family is teal
// (secondary); markers are 2px bars and a translucent span fill, not the 1px trace, so they live
// with 1.92:1 against the waveform. Do not collapse the two back onto one role — they overlap in
// this rect. The trace crossing the loop-span fill is a KNOWN, ACCEPTED under-floor pair (2.25:1
// against a 3:1 floor), and no trace value fixes it — see the two-neighbour rule in
// core/ui/CLAUDE.md. If it is ever resolved, the FILL is what changes; do not nudge a color to
// chase it.
//
// START is deliberately NOT accent/primary, which is what the design called for: accent/primary
// IS the waveform's own fill, so a primary START mark would be 1:1 against the material it marks
// — worse than the teal it replaced, not better. overlay/trace is the one role that clears 3:1
// against BOTH the lime and bg/base (core/ui/CLAUDE.md's two-neighbour ceiling sits exactly on
// it), so it is the only ink that can carry a distinct always-in-effect mark here. It reads
// apart from the envelope trace by shape: a straight full-height column under a solid triangle
// cap, never a curve.
constexpr Role kRoleStartMarker = Role::OverlayTrace;
constexpr Role kRoleLoopMarker = Role::AccentSecondary;
// Mark weights. A Disabled mark (loop off, or Trigger) keeps its position and its cap so the
// information survives the state; the crossfade is a SOFT boundary and rides below the loop
// pair's weight at rest.
constexpr float kMarkAlpha = 1.0f;
constexpr float kMarkAlphaXfade = 0.7f;
constexpr float kMarkAlphaDisabled = 0.4f;
// The dashed crossfade line: a 3 px stroke every 6 px down the band.
constexpr int kDashOn = 3;
constexpr int kDashPeriod = 6;
// The ingredient ghost's weight relative to the audible wedge, and the hairline it draws at
// rest. It fills in only while the crossfade handle is hovered or dragged — the relationship is
// revealed when the user asks about it, not permanently.
constexpr float kGhostAlpha = 0.5f;
constexpr int kGhostHairlinePx = 1;
// Envelope-handle half-extents. Grabbed grows and hollows out; kNodeGrabRadius (envelope_edit)
// is the PICK radius and is unrelated — a handle may draw larger than it without widening any
// hit region.
@@ -46,9 +72,91 @@ constexpr int kEnvHandleRingPx = 2;
// Both envelope traces — staged and drawn — are one grammar and one weight. Two pixels is what
// reads as a trace rather than a hairline over the waveform behind it.
constexpr float kEnvTracePx = 2.0f;
const char* markLabel(WaveMark m) {
switch (m) {
case WaveMark::kStart: return "START";
case WaveMark::kLoopStart: return "LOOP";
case WaveMark::kLoopEnd: return "END";
case WaveMark::kCrossfade: return "XFADE";
case WaveMark::kCount: break;
}
return "";
}
// Font::Micro is proportional, so this is a generous per-character estimate: the label box may
// end up wider than the glyphs, never narrower — an under-estimate would let the suppression
// rule place two boxes that visibly collide.
constexpr int kMicroCharPx = 6;
int markLabelWidth(WaveMark m) {
int n = 0;
for (const char* s = markLabel(m); *s; ++s) ++n;
return n * kMicroCharPx;
}
// One mark's cap glyph, drawn inside the cap rect the hit-test uses. The four shapes ARE the
// marks' identities — a label may be suppressed, a cap never is.
void drawMarkCap(LICE_IBitmap* bmp, WaveMark which, const Rect& cap, int mx, LICE_pixel ink,
float alpha) {
if (cap.empty()) return;
const int top = cap.y;
const int bot = cap.bottom();
const int arm = kMarkerHandleHalfWidth; // the cap's own half-width, so glyph == grip
switch (which) {
case WaveMark::kStart:
// A play flag: it points into the material that will play.
LICE_FillTriangle(bmp, mx - 1, top, mx - 1, bot, mx - 1 + arm + 2, (top + bot) / 2,
ink, alpha, 0);
break;
case WaveMark::kLoopStart:
// '[' — opens right, into the span.
LICE_FillRect(bmp, mx - 1, top, 2, cap.height, ink, alpha, 0);
LICE_FillRect(bmp, mx - 1, top, arm + 1, 2, ink, alpha, 0);
LICE_FillRect(bmp, mx - 1, bot - 2, arm + 1, 2, ink, alpha, 0);
break;
case WaveMark::kLoopEnd:
// ']' — opens left, into the span. The opposed pair reads as an enclosure.
LICE_FillRect(bmp, mx - 1, top, 2, cap.height, ink, alpha, 0);
LICE_FillRect(bmp, mx - arm, top, arm + 1, 2, ink, alpha, 0);
LICE_FillRect(bmp, mx - arm, bot - 2, arm + 1, 2, ink, alpha, 0);
break;
case WaveMark::kCrossfade:
// A ramp whose hypotenuse rises toward the seam — the fade's own shape.
LICE_FillTriangle(bmp, mx - 1, bot - 1, mx - 1 + arm, bot - 1, mx - 1 + arm, top,
ink, alpha, 0);
break;
case WaveMark::kCount:
break;
}
}
// The crossfade region over [f0, f1) as a top-and-bottom edge wedge. NEVER a fill: the audible
// region sits INSIDE the loop span, and a translucent fill there would stack on the loop fill,
// making the already-accepted 2.25:1 trace pair worse. `filled` false draws the resting ghost —
// a hairline dashed outline of the same wedge.
void drawCrossfadeWedge(LICE_IBitmap* bmp, const OverlayArea& overlay, std::int64_t frames,
std::int64_t f0, std::int64_t f1, LICE_pixel ink, float alpha,
bool filled) {
const Rect& r = overlay.rect;
const int x0 = frameToX(overlay, frames, f0);
const int x1 = frameToX(overlay, frames, f1);
if (x1 <= x0 || r.empty()) return;
for (int x = x0; x < x1; ++x) {
const int h = crossfadeWedgeHeight(x0, x1, x);
if (h <= 0) continue;
if (filled) {
LICE_FillRect(bmp, x, r.y, 1, h, ink, alpha, 0);
LICE_FillRect(bmp, x, r.bottom() - h, 1, h, ink, alpha, 0);
} else if ((x - x0) % kDashPeriod < kDashOn) {
LICE_FillRect(bmp, x, r.y + h - kGhostHairlinePx, 1, kGhostHairlinePx, ink, alpha, 0);
LICE_FillRect(bmp, x, r.bottom() - h, 1, kGhostHairlinePx, ink, alpha, 0);
}
}
}
} // namespace
void ReaSamplerEditor::paintWaveform(LICE_IBitmap* bmp, const Rect& band) {
void ReaSamplerEditor::paintWaveform(LICE_IBitmap* bmp, const Rect& band,
const SetupMarkers& m) {
fillSurface(bmp, toKitBox(band), Role::BgBase, InteractionState::Rest);
if (band.empty()) return;
@@ -94,47 +202,92 @@ void ReaSamplerEditor::paintWaveform(LICE_IBitmap* bmp, const Rect& band) {
// stereo view reads one loop region rather than two.
const OverlayArea& overlay = surface.overlay;
const Rect& overlayRect = overlay.rect;
const SetupMarkers m = pickedMarkers(frames);
if (m.hasLoop && m.loopEnd > m.loopStart) {
const int lx = frameToX(overlay, frames, m.loopStart);
const int rx = frameToX(overlay, frames, m.loopEnd);
if (rx > lx) {
LICE_FillRect(bmp, lx, overlayRect.y, rx - lx, overlayRect.height,
toLice(roleColor(kRoleLoopMarker)),
static_cast<float>(kLoopSpanFillAlpha), 0);
}
const bool loopLive = loopControlsLive();
const bool loopOn = m.hasLoop && loopLive;
const WaveMarks marks = waveMarksFor(m);
const LICE_pixel loopInk = toLice(roleColor(kRoleLoopMarker));
const int lx = frameToX(overlay, frames, m.loopStart);
const int rx = frameToX(overlay, frames, m.loopEnd);
if (loopOn && rx > lx) {
LICE_FillRect(bmp, lx, overlayRect.y, rx - lx, overlayRect.height, loopInk,
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);
}
// The crossfade, in the two places it exists: the AUDIBLE region, over the frames the fade
// actually runs on, and its INGREDIENT — the material one loop length earlier that is being
// mixed in — as a ghost. Drawing only the ingredient (which is what shipped before) put the
// one grab affordance on the wrong side of the loop from the sound it controls.
const int xfadeIdx = static_cast<int>(WaveMark::kCrossfade);
const bool xfadeHot =
(drag_ == DragKind::kWaveMarker && waveMarker_ == WaveMark::kCrossfade) ||
isHovered(HoverKind::kWaveMark, xfadeIdx);
if (loopOn && m.crossfade > 0) {
drawCrossfadeWedge(bmp, overlay, frames, m.loopEnd - m.crossfade, m.loopEnd, loopInk,
kMarkAlpha, /*filled=*/true);
// The clamp is crossfade <= min(loopStart, loopLength), and each half is now visible:
// the ghost's left edge reaches frame 0 exactly at the loopStart bound, and the audible
// wedge's left edge reaches the LOOP mark exactly at the loopLength bound. The user
// sees why the fade stopped growing instead of hitting an invisible wall.
drawCrossfadeWedge(bmp, overlay, frames, m.loopStart - m.crossfade, m.loopStart, loopInk,
kGhostAlpha, /*filled=*/xfadeHot);
}
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) {
const int mx = frameToX(overlay, frames, markerFrames[i]);
const bool loopMarker = (i != 0);
const float alpha = (loopMarker && !m.hasLoop) ? 0.4f : 1.0f;
LICE_FillRect(bmp, mx - 1, overlayRect.y, 2, overlayRect.height,
toLice(roleColor(markerRoles[i])), alpha, 0);
// The state caption, centred in the span: the two OFF states say different things because
// they mean different things, and Trigger's refusal names its own reason.
const char* caption = nullptr;
if (!loopLive) caption = "LOOP - GATE ONLY";
else if (!m.hasLoop) caption = m.parked ? "DRAG TO SET LOOP" : "LOOP OFF";
if (caption != nullptr && rx > lx) {
kitTextCentered(bmp, Rect::ltrb(lx, overlayRect.y, rx, overlayRect.bottom()), caption,
Font::Micro, Role::TextDim);
}
// 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);
// Labels beneath the trace and the handles in z-order; the promoted one is re-drawn ON TOP
// after the overlay, so you always see what you grabbed.
const int promoted =
(drag_ == DragKind::kWaveMarker)
? static_cast<int>(waveMarker_)
: (hover_.kind == HoverKind::kWaveMark ? hover_.index : -1);
int labelW[kWaveMarkCount];
for (int i = 0; i < kWaveMarkCount; ++i) labelW[i] = markLabelWidth(static_cast<WaveMark>(i));
const WaveMarkLabels labels = layoutMarkLabels(overlay, frames, marks, labelW, promoted);
for (int i = 0; i < kWaveMarkCount; ++i) {
if (i == promoted || labels.box[i].empty()) continue;
kitTextCentered(bmp, labels.box[i], markLabel(static_cast<WaveMark>(i)), Font::Micro,
Role::TextDim);
}
// Line + shaped cap per mark, one grammar. A mark whose gesture is refused draws Disabled
// rather than hidden — the position is information the user put there.
for (int i = 0; i < kWaveMarkCount; ++i) {
if (!marks.present[i]) continue;
const WaveMark which = static_cast<WaveMark>(i);
const bool isStart = (which == WaveMark::kStart);
const bool dim = !isStart && !loopOn;
const LICE_pixel ink = isStart ? toLice(roleColor(kRoleStartMarker)) : loopInk;
const float alpha = dim ? kMarkAlphaDisabled
: (which == WaveMark::kCrossfade ? kMarkAlphaXfade : kMarkAlpha);
const int mx = frameToX(overlay, frames, marks.frame[i]);
if (which == WaveMark::kCrossfade) {
// Dashed: a soft boundary, not a hard one.
for (int y = overlayRect.y; y < overlayRect.bottom(); y += kDashPeriod) {
const int h = (std::min)(kDashOn, overlayRect.bottom() - y);
LICE_FillRect(bmp, mx - 1, y, 2, h, ink, alpha, 0);
}
} else {
LICE_FillRect(bmp, mx - 1, overlayRect.y, 2, overlayRect.height, ink, alpha, 0);
}
drawMarkCap(bmp, which, markerHandleRect(overlay, frames, marks.frame[i]), mx, ink,
alpha);
}
paintEnvelopeOverlay(bmp, overlay, frames);
if (promoted >= 0 && promoted < kWaveMarkCount && !labels.box[promoted].empty()) {
kitTextCentered(bmp, labels.box[promoted], markLabel(static_cast<WaveMark>(promoted)),
Font::Micro, Role::TextPrimary);
}
}
void ReaSamplerEditor::paintSplineOverlay(LICE_IBitmap* bmp, const OverlayArea& waveArea) {
+19 -50
View File
@@ -18,7 +18,6 @@
#include "core/util/file_bytes.h" // shared whole-file loader
#include "ext_keys.h"
#include "core/instrument/bake/bake_plan.h" // bakeWindowNeedsHold (the Hold predicate)
#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/instrument_bake.h" // the deferred bake the sync tick runs
#include "shell/instrument/reaper_bridge.h"
@@ -34,8 +33,6 @@ 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;
@@ -218,15 +215,16 @@ void ReaSamplerEditor::loadSelection(const std::string& id) {
}
ReaSamplerEditor::SetupMarkers ReaSamplerEditor::pickedMarkers(std::int64_t frames) const {
SetupMarkers m;
// Seed from the bank's intrinsic loop (fact about the file), then let the parameter set's
// override win (the instrument's performance choice). Read the loop intrinsic from the
// live bank blob (the same path selectSample uses); when that is not readable (extension
// absent / not yet parsed) the instance-owned ref carries the same intrinsics. Skipped
// entirely once an override is already set — it would just be overwritten below, and the
// bridge read + JSON parse it costs is real (mouseDownWaveform's arbitration calls this on
// every waveform click, not just marker grabs, to know whether a tab or marker candidate
// hits at all).
instrument::ui::StoredLoop stored;
stored.override_ = params_.loopOverride;
stored.crossfade = params_.loopCrossfadeFrames;
stored.startPoint = params_.startPoint;
// Read the loop intrinsic from the live bank blob (the same path selectSample uses); when
// that is not readable (extension absent / not yet parsed) the instance-owned ref carries
// the same intrinsics. Skipped entirely once an override is already set — the resolve would
// discard it, and the bridge read + JSON parse it costs is real (mouseDownWaveform's
// arbitration calls this on every waveform click, not just marker grabs, to know whether a
// cap or column candidate hits at all).
if (processor_ && !params_.loopOverride) {
std::optional<SelectedSample> sel;
auto banksJson =
@@ -236,31 +234,9 @@ ReaSamplerEditor::SetupMarkers ReaSamplerEditor::pickedMarkers(std::int64_t fram
const SampleRefs refs = processor_->sampleRefs();
if (const SelectedSample* r = findRef(refs, selectedId_)) sel = *r;
}
if (sel && sel->loop.hasLoop) {
m.hasLoop = true;
m.loopStart = sel->loop.start;
m.loopEnd = sel->loop.end;
}
if (sel) stored.intrinsic = sel->loop;
}
// The parameter set's override (loop + start) supersedes the intrinsic.
if (params_.loopOverride) {
m.hasLoop = params_.loopOverride->hasLoop;
m.loopStart = params_.loopOverride->start;
m.loopEnd = params_.loopOverride->end;
}
if (params_.startPoint) m.start = *params_.startPoint;
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;
return instrument::ui::resolveLoopMarks(stored, frames);
}
bool ReaSamplerEditor::HoldNeedKey::operator==(const HoldNeedKey& o) const {
@@ -303,20 +279,13 @@ bool ReaSamplerEditor::resolveBakeHoldNeeded() {
}
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;
// 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;
// OFF parks the crossfade at 0 too — loadSelection's own clear (a fresh capture has no
// loop to fade) is the same rule; leaving a stale length here would silently re-apply it
// (clamped) the next time a loop is dragged back in.
params_.loopCrossfadeFrames = loop.hasLoop ? m.crossfade : 0;
params_.startPoint = m.start;
// Write the edited markers into the parameter set as the loop/start override; the fold
// itself is the pure loop_marks module's. The bank intrinsic is never written (read-only
// bank consumer).
const instrument::ui::LoopWrite w = instrument::ui::applyLoopMarks(m);
params_.loopOverride = w.loop;
params_.loopCrossfadeFrames = w.crossfade;
params_.startPoint = w.start;
}
int ReaSamplerEditor::effectiveRoot() const {
+38 -20
View File
@@ -19,7 +19,9 @@
#include "core/instrument/ui/envelope_edit.h" // EnvClampBounds / NodeHit (envelope node hit-test/edit)
#include "core/instrument/ui/envelope_overlay.h" // StageEnvelope / EnvNode (envelope overlay draw seam)
#include "core/instrument/ui/knob_deck.h" // DeckGroupDesc / DeckLayout (the deck band)
#include "core/instrument/ui/loop_marks.h" // LoopMarks (the loop enable's state machine)
#include "core/instrument/ui/sample_bands.h" // SampleBands (the band-stack allocator)
#include "core/instrument/ui/waveform_view.h" // WaveMark / WaveMarks (the overlay's marks)
#include "core/instrument/ui/spline_edit.h" // the shared point-editing grammar
#include "core/instrument/ui/sample_chrome.h" // ChromeRects (chrome-band interior)
#include "core/audio/peaks.h" // Envelope (the cached peak thumbnail)
@@ -104,12 +106,13 @@ private:
// [0, DeckParam::kCount) is what keeps liveCommitFor answering "not a live control".
static constexpr int kBakeHoldKnobId = -3;
// The waveform markers on the waveform band: start-point + the sustain loop's two ends,
// 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
// The waveform band's four marks, in draw + hit order. The crossfade is NOT part of the
// full-height column hit-test — it answers only in its cap (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 };
using WaveMarker = instrument::ui::WaveMark;
// What those four marks are showing — see pickedMarkers.
using SetupMarkers = instrument::ui::LoopMarks;
// 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
@@ -125,6 +128,9 @@ private:
kBrowseCancel, // the Browse modal "Cancel" button
kChanMono, // the mono channel-mode segment
kChanStereo, // the stereo channel-mode segment
kLoopOff, // the loop enable's Off segment
kLoopOn, // the loop enable's On segment
kWaveMark, // a waveform overlay mark (index = WaveMark ordinal); promotes its label
kPreview, // the preview-trigger button
kBake, // the resample-bake trigger
kControl, // a knob-deck element (index = control id)
@@ -162,12 +168,14 @@ private:
// --- Band painters (one TU each, mirroring the input side) ---
// Chrome: title band + Browse nav + the control row (root strip, preview, velocity knob,
// channel toggle).
void paintChrome(LICE_IBitmap* bmp, const FaceLayout& fl, bool empty);
void paintChrome(LICE_IBitmap* bmp, const FaceLayout& fl, bool empty,
const SetupMarkers& marks);
// The hovered piano key's note-name chip. Drawn after every band — it overhangs the
// chrome into whatever is below it.
void paintChromeTooltip(LICE_IBitmap* bmp, const FaceLayout& fl, int w, int h);
// Waveform: the channel lane(s), the loop/start markers, and the envelope overlay.
void paintWaveform(LICE_IBitmap* bmp, const Rect& band);
// Waveform: the channel lane(s), the four marks, and the envelope overlay. `marks` is
// resolved once per paint by paintSample — see there.
void paintWaveform(LICE_IBitmap* bmp, const Rect& band, const SetupMarkers& marks);
// Decks: the group fence + caption + compact caption toggles + radial knobs with
// label<->value swap on hover/drag.
void paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl);
@@ -243,6 +251,9 @@ private:
// mouse-down branches but are read-only. Windows-only.
void resolveHover(int x, int y);
HoverTarget hoverChrome(const FaceLayout& fl, int x, int y) const;
// Marks only — the cap strip. Everything else in the band already reports its own state
// through the drag, so nothing else on the overlay needs a hover.
HoverTarget hoverWaveform(const FaceLayout& fl, int x, int y);
HoverTarget hoverDeck(const FaceLayout& fl, int x, int y) const;
HoverTarget hoverBrowse(int w, int h, int x, int y) const;
HoverTarget hoverCurvePopup(int w, int h, int x, int y) const;
@@ -326,20 +337,27 @@ private:
// instance's own SampleRefs as the self-contained fallback. "" when unresolvable.
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. 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)
};
// The effective loop + start markers for the loaded capture. The state machine behind them
// — which stored source wins, when the pair re-parks, what the two OFF states mean — is the
// pure loop_marks module's; this only reads the bank intrinsic it cannot see.
SetupMarkers pickedMarkers(std::int64_t frames) const;
// Whether the loop controls answer at all: the sustain loop is Gate-only, so in Trigger the
// marks and the chrome enable draw Disabled and inert. Reads the mode AFTER the drawn-EG
// fold, so a drawn envelope disables them through the same predicate.
bool loopControlsLive() const;
// Which marks the band DRAWS, and which of those accept a grab. They differ in exactly one
// place — Trigger, where the loop marks stay drawn (hiding a set loop on a mode flip would
// destroy information the user put there) but refuse every gesture, because that refusal
// comes from the engine and no drag can talk it out of it.
instrument::ui::WaveMarks waveMarksFor(const SetupMarkers& m) const;
instrument::ui::WaveMarks grabbableMarks(const SetupMarkers& m) const;
// Flips the enable. `on` false retains the span and the crossfade — that retention is the
// whole difference between a toggle and a delete button.
void setLoopEnabled(bool on);
// Whether the loaded sound's bake window needs the user's Hold — the pure predicate
// (bake_plan.h) answered against the markers this face is showing. Decodes and reads the
// bank, so it is called on the sync tick, not per paint, and memoized against the inputs