Files
reasampler/src/shell/instrument/editor_input_waveform.cpp
T
daniel aedcc6976c fix: pin the waveform arbitration in a testable predicate, close round-4 review minors
Extracts resolveWaveformClaim (core/instrument/ui/spline_edit) so the shell's node/tab/marker click resolution is unit-tested directly, not just its input geometry; folds the staged-envelope node into it; fixes comment accuracy, a cost regression, and test fidelity issues.
2026-08-01 00:17:06 -04:00

276 lines
14 KiB
C++

// editor_input_waveform.cpp — the WAVEFORM band's input: grabbing an envelope node or a
// start/loop marker, and resolving both drags live against the pure inverse maps
// (envelope_edit, waveform_view). Windows-only.
//
// Overlay contract: see waveform_view.h's WaveformSurface.
#include "shell/instrument/reasampler_editor.h"
#ifdef _WIN32
#include <algorithm>
#include <cstdint>
#include <vector>
#include "core/instrument/engine/loop/loop_span.h" // maxCrossfade (the shared drag-clamp bound)
#include "core/instrument/engine/velocity_curve.h" // kCurveNodeGrabRadius (target-size arbitration)
#include "core/instrument/ui/envelope_edit.h" // nodeAtPoint / resolveNodeDrag
#include "core/instrument/ui/spline_edit.h" // the shared point-editing grammar
#include "core/instrument/ui/waveform_view.h" // waveformOverlayArea / markerAtPoint / snap
#include "shell/instrument/editor_internal.h"
#include "shell/instrument/reasampler_processor.h"
namespace reasampler::vst {
using namespace reasampler::ui;
using namespace reasampler::instrument::ui;
using instrument::engine::loop::maxCrossfade;
using instrument::engine::kCurveNodeGrabRadius;
bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) {
const std::vector<AudioSample>& pcm = monoPcmFor(selectedId_);
const std::int64_t frames = static_cast<std::int64_t>(pcm.size());
if (frames <= 0) return false;
const OverlayArea overlay = waveformOverlayArea(fl.bands.waveform);
const DeckEnableState gates = deckEnableState();
const bool splineLive = overlayIsSpline() && overlayEnvEnabled(overlayEnv_, gates);
const SplineGesture gesture = (GetKeyState(VK_CONTROL) & 0x8000) != 0
? SplineGesture::kControlLeft
: SplineGesture::kLeft;
// The staged envelope's draggable node and the drawn contour's node are mutually exclusive
// (overlayEnvInert flips the staged one inert exactly when its envelope is in Spline mode),
// so at most one of the two hit-tests below is ever live for the same click — both feed the
// SAME arbitration slot below rather than either one getting its own check-order return.
const double rate = liveSampleRate();
const bool nodesLive =
overlayEnv_ != OverlayEnv::kNone && !overlayEnvInert(overlayEnv_, gates);
NodeHit envNodeHit;
StageEnvelope env;
if (rate > 0.0 && nodesLive) {
const std::int64_t startFrame = params_.startPoint.value_or(0);
env = packEnvelope(overlayEnv_, params_.play, frames, startFrame);
const double totalSeconds = static_cast<double>(frames) / rate;
envNodeHit = nodeAtPoint(env, overlay, totalSeconds, x, y);
}
const SetupMarkers m = pickedMarkers(frames);
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).
WaveformClaim node;
if (envNodeHit.hit) {
constexpr std::int64_t side = 2 * kNodeGrabRadius + 1;
node = {true, side * side};
} else if (splineLive) {
const VelocityCurve::Box box = splineOverlayBox(overlay);
// Also require strict in-box, matching splineOverlayClick's own narrowing (spline_edit.h's
// grammar note) — otherwise this candidate could "win" the arbitration below for a click
// splineOverlayClick would then refuse, silently swallowing it instead of falling through
// to the tab/marker checks.
if (contains(overlay.rect, x, y) && splineFor(overlayEnv_).pointAtPixel(box, x, y) >= 0) {
constexpr std::int64_t side = 2 * kCurveNodeGrabRadius + 1;
node = {true, side * side};
}
}
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{};
// 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 WaveformClaim marker =
(markerHit >= 0)
? WaveformClaim{true, static_cast<std::int64_t>(2 * kMarkerGrabWidth + 1) *
overlay.rect.height}
: WaveformClaim{};
switch (resolveWaveformClaim(node, tab, marker, gesture)) {
case WaveformClaimant::kNode:
if (envNodeHit.hit) {
drag_ = DragKind::kEnvNode;
envNode_ = envNodeHit.node;
dragStartX_ = x;
dragStartY_ = y;
dragStartEnv_ = env;
dragSampleFrames_ = frames;
dragStartParams_ = params_;
return true; // node moves once the cursor drags
}
return splineOverlayClick(overlay, x, y, gesture, /*addOnEmptySpace=*/false);
case WaveformClaimant::kTab:
beginMarkerDrag(WaveMarker::kLoopXfade, m, frames, x);
return true;
case WaveformClaimant::kMarker:
beginMarkerDrag(static_cast<WaveMarker>(markerHit), m, frames, x);
return true;
case WaveformClaimant::kNone:
break;
}
// Nothing else wanted the click: now the drawn contour may take the empty space.
if (splineLive) return splineOverlayClick(overlay, x, y, gesture, /*addOnEmptySpace=*/true);
return false;
}
bool ReaSamplerEditor::splineOverlayClick(const OverlayArea& waveArea, int x, int y,
SplineGesture gesture, bool addOnEmptySpace) {
const VelocityCurve::Box box = splineOverlayBox(waveArea);
VelocityCurve& contour = splineFor(overlayEnv_);
SplineEdit edit = resolveSplineEdit(contour, box, gesture, x, y);
// The overlay's outside-box narrowing — see spline_edit.h's grammar note for why.
if (edit.kind != SplineEditKind::kNone && edit.kind != SplineEditKind::kAdd &&
!(x >= box.left && x < box.left + box.width && y >= box.top && y < box.top + box.height)) {
edit = SplineEdit{};
}
if (edit.kind == SplineEditKind::kAdd && !addOnEmptySpace) return false;
switch (edit.kind) {
case SplineEditKind::kNone:
return false;
case SplineEditKind::kDelete:
// deletePoint refuses the two endpoints, so a right-click on one is a safe no-op.
if (!contour.deletePoint(static_cast<std::size_t>(edit.index))) return true;
hover_ = HoverTarget{}; // a stale index would light a shifted node
commitAndReload();
return true;
case SplineEditKind::kToggleHard:
if (!contour.toggleHard(static_cast<std::size_t>(edit.index))) return true;
commitAndReload();
return true;
case SplineEditKind::kAdd:
case SplineEditKind::kGrab:
break;
}
// Snapshot BEFORE the add so a capture-loss rollback cancels the in-flight point too
// (the same contract the other parameter-editing drags keep).
dragStartParams_ = params_;
int index = edit.index;
if (edit.kind == SplineEditKind::kAdd) {
const VelocityPoint p = contour.pointFromPixel(box, x, y);
index = contour.addPoint(p.velocity, p.value);
// At the ceiling: refused, contour untouched, nothing to roll back. Swallow the click
// rather than letting it fall through to a marker grab under the cursor.
if (index < 0) return true;
}
drag_ = DragKind::kSplineNode;
curvePointIndex_ = index;
dragStartCurve_ = contour; // AFTER the add — resolvePointDrag's delta base
// The release-time drag-off-delete bound (onMouseUp), matching the popup's kCurveNode use
// of the same field — the two spline surfaces share one drag-off grammar, not just the
// click grammar.
dragCurveRect_ = waveArea.rect;
dragStartX_ = x;
dragStartY_ = y;
invalidate(); // live feedback; the commit lands on WM_LBUTTONUP
return true;
}
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_;
if (drag_ == DragKind::kSplineNode) {
// Same absolute-delta contract as the popup's node drag, against the grab-time contour
// and the overlay's own box.
if (curvePointIndex_ < 0) return;
splineFor(overlayEnv_) = VelocityCurve::resolvePointDrag(
dragStartCurve_, static_cast<std::size_t>(curvePointIndex_),
splineOverlayBox(overlay), dx, y - dragStartY_);
invalidate(); // live feedback; commit on release
return;
}
if (drag_ == DragKind::kEnvNode) {
// Resolve the grabbed envelope node's new params from the pixel delta (through the
// pure envelope_edit inverse map, clamped), then unpack them back onto the parameter
// set. The StageEnvelope was snapshotted at grab (dragStartEnv_) so the delta is
// absolute.
const std::int64_t frames = dragSampleFrames_;
const double rate = liveSampleRate();
if (frames <= 0 || rate <= 0.0) return;
const double totalSeconds = static_cast<double>(frames) / rate;
const StageEnvelope edited = resolveNodeDrag(dragStartEnv_, envNode_, overlay,
totalSeconds, envClampBounds(), dx,
y - dragStartY_);
unpackEnvelope(overlayEnv_, edited, params_.play);
if (dragCommitsLive(DragKind::kEnvNode)) commitLive();
invalidate(); // live feedback; commit on WM_LBUTTONUP
return;
}
// kWaveMarker: resolve the grabbed marker's new frame from the pixel delta,
// zero-crossing-snap it against the decoded PCM, apply the inter-marker clamps, and write
// the override live.
const std::int64_t frames = dragSampleFrames_;
if (frames <= 0) return;
// 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[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. 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) {
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.
SetupMarkers m = dragStartMarkers_;
if (waveMarker_ == WaveMarker::kStart) {
m.start = newFrame;
} else if (waveMarker_ == WaveMarker::kLoopStart) {
m.loopStart = (std::min)(newFrame, m.loopEnd);
m.hasLoop = true;
} 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;
// Shares resolveLoop's own bound (loop_span.h's maxCrossfade) so the handle can't be
// dragged somewhere the engine would silently clamp back.
m.crossfade = (std::min)(m.crossfade, maxCrossfade(m.loopStart, m.loopEnd - m.loopStart));
if (m.crossfade < 0) m.crossfade = 0;
applyMarkers(m);
invalidate(); // live feedback; the commit lands on release
}
} // namespace reasampler::vst
#endif // _WIN32