// 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 #include #include #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& pcm = monoPcmFor(selectedId_); const std::int64_t frames = static_cast(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 = dragModifiers().ctrl ? 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(frames) / rate; envNodeHit = nodeAtPoint(env, overlay, totalSeconds, x, 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), 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 AND the slot's own nominal area // (every cap is one markerHandleRect, so it moved from the old clipped-actual measure — 60 at // frame 0 — to the nominal 110); the `cap < node < column` ordering held anyway, because 110 // is still under the node's fixed pick-box area. 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 int capHit = capAtPoint(overlay, frames, grabbable, x, y); const WaveformClaim tab = (capHit >= 0) ? WaveformClaim{true, static_cast(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 = (grabbable.present[static_cast(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(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; dragMods_ = dragModifiers(); dragStartEnv_ = env; dragSampleFrames_ = frames; dragStartParams_ = params_; // Peer of mouseDownDeck's bracket. LATCHING with no id named, because a node or // knot drag can move more than one exposed parameter and the grab cannot know // which; the release and capture-lost paths close it generically. if (processor_) processor_->beginParamGestureLatch(); return true; // node moves once the cursor drags } return splineOverlayClick(overlay, x, y, gesture, /*addOnEmptySpace=*/false); case WaveformClaimant::kTab: beginMarkerDrag(static_cast(capHit), m, frames, x); return true; case WaveformClaimant::kMarker: beginMarkerDrag(static_cast(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(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(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; } 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(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; 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(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(frames) / rate; const DragModifiers mods = dragModifiers(); if (mods != dragMods_) { // Re-anchor (see dragMods_): the node's CURRENT params and the cursor's current // position become the origin, so the flip changes only the rate. Re-packing the // grab envelope is what makes that true for the delta this resolver measures. dragStartEnv_ = packEnvelope(overlayEnv_, params_.play, frames, params_.startPoint.value_or(0)); dragStartX_ = x; dragStartY_ = y; dragMods_ = mods; } const StageEnvelope edited = resolveNodeDrag(dragStartEnv_, envNode_, overlay, totalSeconds, envClampBounds(), x - dragStartX_, y - dragStartY_, mods); 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(waveMarker_); const std::int64_t startVals[4] = {dragStartMarkers_.start, dragStartMarkers_.loopStart, dragStartMarkers_.loopEnd, dragStartMarkers_.loopEnd - dragStartMarkers_.crossfade}; std::int64_t newFrame = resolveDragFrame(overlay, frames, startVals[idx], dx); // Snap to a zero crossing in the decoded PCM, but only one within kZeroCrossingSnapPx of // where the mark was dropped. 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. Ctrl defeats the snap outright, the same "get out of // the way, I want exactness" role it plays as the knobs' fine drag. `[verify — DAW]` this // `GetKeyState` read has no test target — nothing in CI observes the defeat actually landing. const std::vector& pcm = monoPcmFor(selectedId_); if (!pcm.empty() && waveMarker_ != WaveMarker::kCrossfade && !dragModifiers().ctrl) { newFrame = snapToZeroCrossing(pcm.data(), frames, newFrame, zeroCrossingSnapFrames(overlay, frames)); } // 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 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; } 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 { // 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; // 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