Merge pS-w1-t1-env: envelope_overlay + envelope_edit pure modules (S-VIEW-3 core)
This commit is contained in:
@@ -762,6 +762,24 @@ add_library(param_slider STATIC src/vst/param_slider.cpp)
|
||||
target_include_directories(param_slider PUBLIC src/vst)
|
||||
target_link_libraries(param_slider PUBLIC editor_geometry)
|
||||
|
||||
# envelope_overlay (Phase S-VIEW-3) — PURE amp-envelope -> polyline geometry for the Sample-view
|
||||
# envelope overlay: AHDSR (Gate) / fade+%-length (Trigger) params + the sample's wall-clock
|
||||
# duration -> a breakpoint polyline in the waveform rect, at the same time base waveform_view maps.
|
||||
# The mirror of waveform_view / param_slider; links editor_geometry for the shared Rect.
|
||||
# Deliberately engine-free (no sample_map / sampler_core) — the shell packs the zone's stored
|
||||
# AdsrSeconds / TriggerParams into the small AmpEnvelope view struct. NEITHER SDK.
|
||||
add_library(envelope_overlay STATIC src/vst/envelope_overlay.cpp)
|
||||
target_include_directories(envelope_overlay PUBLIC src/vst)
|
||||
target_link_libraries(envelope_overlay PUBLIC editor_geometry)
|
||||
|
||||
# envelope_edit (Phase S-VIEW-3) — PURE node hit-test + pixel-delta -> clamped-param inverse map
|
||||
# for the draggable envelope nodes: monotonic-in-time + range-clamped (against caller-supplied
|
||||
# slider maxima) so a drag can never produce a param a slider couldn't. The mirror of card_drag;
|
||||
# links envelope_overlay for the shared node vocabulary + the timeToX/levelToY maps. NEITHER SDK.
|
||||
add_library(envelope_edit STATIC src/vst/envelope_edit.cpp)
|
||||
target_include_directories(envelope_edit PUBLIC src/vst)
|
||||
target_link_libraries(envelope_edit PUBLIC envelope_overlay)
|
||||
|
||||
add_executable(editor_geometry_tests tests/test_editor_geometry.cpp)
|
||||
target_link_libraries(editor_geometry_tests PRIVATE editor_geometry)
|
||||
add_test(NAME editor_geometry_tests COMMAND editor_geometry_tests)
|
||||
@@ -817,6 +835,19 @@ add_executable(param_slider_tests tests/test_param_slider.cpp)
|
||||
target_link_libraries(param_slider_tests PRIVATE param_slider)
|
||||
add_test(NAME param_slider_tests COMMAND param_slider_tests)
|
||||
|
||||
# envelope_overlay (S-VIEW-3): the pure amp-envelope -> polyline geometry (Gate AHDSR + Trigger
|
||||
# fade/%-length) at the waveform time base. Links ONLY envelope_overlay (+ its editor_geometry
|
||||
# dep) — NEITHER SDK — the plain-data-boundary proof.
|
||||
add_executable(envelope_overlay_tests tests/test_envelope_overlay.cpp)
|
||||
target_link_libraries(envelope_overlay_tests PRIVATE envelope_overlay)
|
||||
add_test(NAME envelope_overlay_tests COMMAND envelope_overlay_tests)
|
||||
|
||||
# envelope_edit (S-VIEW-3): the pure node hit-test + clamped/monotonic pixel->param inverse map.
|
||||
# Links ONLY envelope_edit (+ its envelope_overlay dep) — NEITHER SDK.
|
||||
add_executable(envelope_edit_tests tests/test_envelope_edit.cpp)
|
||||
target_link_libraries(envelope_edit_tests PRIVATE envelope_edit)
|
||||
add_test(NAME envelope_edit_tests COMMAND envelope_edit_tests)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
// envelope_edit.cpp — see envelope_edit.h. Pure inverse map + hit-test; no host types.
|
||||
|
||||
#include "envelope_edit.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdlib> // std::abs
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
namespace {
|
||||
|
||||
double clamp(double v, double lo, double hi) {
|
||||
if (v < lo) return lo;
|
||||
if (v > hi) return hi;
|
||||
return v;
|
||||
}
|
||||
|
||||
// Seconds represented by one horizontal pixel under the overlay's linear time base. Zero when the
|
||||
// area is degenerate (the caller then produces no motion). Matches envelope_overlay::timeToX.
|
||||
double secondsPerPixel(const Rect& area, double totalSeconds) {
|
||||
const int w = std::max(0, area.width());
|
||||
if (w <= 0 || totalSeconds <= 0.0) return 0.0;
|
||||
return totalSeconds / static_cast<double>(w);
|
||||
}
|
||||
|
||||
// Level (0..1) represented by one vertical pixel. levelToY spans (height-1) rows for [0,1], so one
|
||||
// pixel is 1/(height-1). Zero when degenerate. Matches envelope_overlay::levelToY.
|
||||
double levelPerPixel(const Rect& area) {
|
||||
const int h = std::max(0, area.height());
|
||||
if (h <= 1) return 0.0;
|
||||
return 1.0 / static_cast<double>(h - 1);
|
||||
}
|
||||
|
||||
// True for the nodes the user can grab-and-drag (Origin + ReleaseStart are draw-only anchors).
|
||||
bool isDraggable(EnvNode n) {
|
||||
switch (n) {
|
||||
case EnvNode::Origin:
|
||||
case EnvNode::ReleaseStart:
|
||||
return false;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
NodeHit nodeAtPoint(const AmpEnvelope& env, const Rect& area, double totalSeconds, int x, int y) {
|
||||
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, area, totalSeconds);
|
||||
// First-match in draw order (deterministic tie-break), skipping non-draggable anchors.
|
||||
for (const EnvVertex& v : poly) {
|
||||
if (!isDraggable(v.node)) continue;
|
||||
if (std::abs(x - v.x) <= kNodeGrabRadius && std::abs(y - v.y) <= kNodeGrabRadius) {
|
||||
return NodeHit{true, v.node};
|
||||
}
|
||||
}
|
||||
return NodeHit{false, EnvNode::Origin};
|
||||
}
|
||||
|
||||
AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect& area,
|
||||
double totalSeconds, const EnvClampBounds& bounds,
|
||||
int dxPixels, int dyPixels) {
|
||||
AmpEnvelope out = grabEnv;
|
||||
if (!isDraggable(node)) return out;
|
||||
|
||||
const double secPerPx = secondsPerPixel(area, totalSeconds);
|
||||
if (secPerPx <= 0.0) return out; // degenerate area / duration — no motion
|
||||
const double dSec = static_cast<double>(dxPixels) * secPerPx;
|
||||
|
||||
switch (node) {
|
||||
// --- Gate: each cumulative-time node edits its OWN segment duration. Non-negative
|
||||
// durations ARE the monotonic-in-time guarantee (a node can never cross a neighbour
|
||||
// because every segment stays >= 0), so the [0, max] clamp is the whole constraint.
|
||||
case EnvNode::AttackEnd:
|
||||
out.attackSeconds = clamp(grabEnv.attackSeconds + dSec, 0.0, bounds.maxAttackSeconds);
|
||||
break;
|
||||
case EnvNode::HoldEnd:
|
||||
out.holdSeconds = clamp(grabEnv.holdSeconds + dSec, 0.0, bounds.maxHoldSeconds);
|
||||
break;
|
||||
case EnvNode::DecayEnd: {
|
||||
// Sustain node: X sets decay time, Y sets sustain level (drag DOWN = higher y = lower
|
||||
// level, so subtract the level delta).
|
||||
out.decaySeconds = clamp(grabEnv.decaySeconds + dSec, 0.0, bounds.maxDecaySeconds);
|
||||
const double lvlPerPx = levelPerPixel(area);
|
||||
const double dLevel = -static_cast<double>(dyPixels) * lvlPerPx;
|
||||
out.sustainLevel = clamp(grabEnv.sustainLevel + dLevel, 0.0, 1.0);
|
||||
break;
|
||||
}
|
||||
case EnvNode::ReleaseEnd:
|
||||
out.releaseSeconds =
|
||||
clamp(grabEnv.releaseSeconds + dSec, 0.0, bounds.maxReleaseSeconds);
|
||||
break;
|
||||
|
||||
// --- Trigger: fades + length are FRACTIONS. X pixels convert to a fraction of the PLAYED
|
||||
// span (fades) or the whole sample (length). Monotonic: fadeIn + fadeOut <= 1 so the
|
||||
// two fade nodes never cross (each clamps against the other), and length in [0, max].
|
||||
//
|
||||
// TRIGGER SEAM — CONVERSION REQUIRED ON BOTH PATHS (Wave 2 shell author, read this):
|
||||
// fadeInFraction/fadeOutFraction in AmpEnvelope are fractions of the played span.
|
||||
// TriggerParams (sampler_core.h) stores the corresponding values as SOURCE FRAMES
|
||||
// (fadeInFrames/fadeOutFrames, int64_t). The shell owes a converter on BOTH directions:
|
||||
// pack (draw): fadeInFrames/fadeOutFrames -> fraction (needs frameCount + rate)
|
||||
// unpack (commit): fraction -> fadeInFrames/fadeOutFrames (same inputs)
|
||||
// See the TRIGGER SEAM note on AmpEnvelope in envelope_overlay.h for the formula.
|
||||
case EnvNode::FadeInEnd: {
|
||||
if (dxPixels == 0) break; // zero-motion grab: no param change, no division
|
||||
const double playSeconds = std::max(0.0, grabEnv.lengthFraction) * totalSeconds;
|
||||
const double dFrac = playSeconds > 0.0 ? dSec / playSeconds : 0.0;
|
||||
const double hi = std::min(bounds.maxFadeInFraction,
|
||||
1.0 - std::max(0.0, grabEnv.fadeOutFraction));
|
||||
out.fadeInFraction = clamp(grabEnv.fadeInFraction + dFrac, 0.0, std::max(0.0, hi));
|
||||
break;
|
||||
}
|
||||
case EnvNode::FadeOutStart: {
|
||||
if (dxPixels == 0) break; // zero-motion grab: no param change, no division
|
||||
// FadeOutStart sits at (1 - fadeOut) of the played span; dragging it LEFT (negative dx)
|
||||
// lengthens the fade-out. So the fade-out fraction moves OPPOSITE the pixel delta.
|
||||
const double playSeconds = std::max(0.0, grabEnv.lengthFraction) * totalSeconds;
|
||||
const double dFrac = playSeconds > 0.0 ? -dSec / playSeconds : 0.0;
|
||||
const double hi = std::min(bounds.maxFadeOutFraction,
|
||||
1.0 - std::max(0.0, grabEnv.fadeInFraction));
|
||||
out.fadeOutFraction = clamp(grabEnv.fadeOutFraction + dFrac, 0.0, std::max(0.0, hi));
|
||||
break;
|
||||
}
|
||||
case EnvNode::LengthEnd: {
|
||||
// LengthEnd sits at lengthFraction of the WHOLE sample; X maps to a fraction of it.
|
||||
const double dFrac = totalSeconds > 0.0 ? dSec / totalSeconds : 0.0;
|
||||
out.lengthFraction = clamp(grabEnv.lengthFraction + dFrac, 0.0, bounds.maxLengthFraction);
|
||||
break;
|
||||
}
|
||||
|
||||
case EnvNode::Origin:
|
||||
case EnvNode::ReleaseStart:
|
||||
break; // unreachable (isDraggable filtered above), kept for switch exhaustiveness
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace reasampler::vst
|
||||
@@ -0,0 +1,94 @@
|
||||
// envelope_edit.h — PURE node hit-test + pixel-delta→clamped-param inverse map for the S-VIEW-3
|
||||
// draggable envelope nodes. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror
|
||||
// of card_drag / waveform_view: the drag arithmetic + clamp/monotonic constraints live here,
|
||||
// unit-tested at the boundaries outside the DAW, while the editor shell (reasampler_editor.cpp)
|
||||
// draws the handles, captures the grab on WM_LBUTTONDOWN, feeds each move's pixel delta back
|
||||
// through here, and commits the resulting params to the zone through the same off-audio-thread
|
||||
// path a slider edit uses.
|
||||
//
|
||||
// TWO SURFACES, ONE MODEL. envelope_overlay owns the params→polyline FORWARD map (draw); this
|
||||
// module owns the pixel→params INVERSE map (edit) + node hit-test. Both read/write the SAME
|
||||
// AmpEnvelope fields (the shell re-reads the zone every paint — no listener chain), so a node
|
||||
// drag and a slider edit are two views on one source of truth and can never diverge.
|
||||
//
|
||||
// THE INVARIANT (S-VIEW-F2). A drag can NEVER produce a param a slider couldn't:
|
||||
// * MONOTONIC IN TIME — a node clamps between its time predecessor and successor, so attack-end
|
||||
// can't pass hold-end, decay can't pass release, etc. Each segment stays >= 0.
|
||||
// * RANGE-CLAMPED — times clamp to the SAME per-param [min,max] the slider enforces; levels
|
||||
// clamp to [0,1]. Because the concrete second/fraction maxima live SHELL-SIDE (param_slider
|
||||
// is deliberately engine-free — the shell owns the 0..1↔domain mapping), the clamp bounds are
|
||||
// CALLER-SUPPLIED here (EnvClampBounds): the shell passes the same maxima it feeds the slider,
|
||||
// so the two surfaces share one clamp by construction.
|
||||
//
|
||||
// WHICH AXES. Time-only nodes (attack-end, hold-end, release-end; fade-in-end, length-end,
|
||||
// fade-out-end) drag on X only. The sustain node (DecayEnd) drags on BOTH axes — its X sets the
|
||||
// decay time, its Y sets the sustain level (the standard ADSR-editor grammar). Origin and the
|
||||
// drawing-only ReleaseStart vertex are NOT draggable.
|
||||
//
|
||||
// Reuses editor_geometry's Rect + the EnvNode / AmpEnvelope / EnvMode types from
|
||||
// envelope_overlay (one shared node vocabulary across draw + edit), and the shared timeToX /
|
||||
// levelToY maps so the handle the overlay drew and the grab region here agree pixel-for-pixel.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "editor_geometry.h" // Rect
|
||||
#include "envelope_overlay.h" // EnvNode, EnvMode, AmpEnvelope, EnvVertex, timeToX/levelToY
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
// The pick radius (px) around a node's drawn point: a grab within this many pixels (in BOTH x and
|
||||
// y) of a node handle grabs it. Mirrors waveform_view's kMarkerGrabWidth — wide enough to grab a
|
||||
// small handle comfortably, narrow enough that adjacent nodes stay distinguishable.
|
||||
inline constexpr int kNodeGrabRadius = 6;
|
||||
|
||||
// The per-param clamp bounds the shell supplies (the SAME maxima its sliders map 0..1 onto). All
|
||||
// are upper bounds in the param's own domain; the lower bound is 0 (each stage >= 0), and the
|
||||
// monotonic-in-time constraint tightens these further at edit time. Defaults are conservative
|
||||
// placeholders; the shell OVERRIDES them with its live slider domain so the clamp matches exactly.
|
||||
struct EnvClampBounds {
|
||||
double maxAttackSeconds = 4.0; // upper bound of the attack slider
|
||||
double maxHoldSeconds = 4.0;
|
||||
double maxDecaySeconds = 4.0;
|
||||
double maxReleaseSeconds = 4.0;
|
||||
// Trigger fades + length are fractions; their natural upper bound is 1.0. Exposed so a shell
|
||||
// that caps a fade below the full span (e.g. 0.5) shares that cap with its slider.
|
||||
double maxFadeInFraction = 1.0;
|
||||
double maxFadeOutFraction = 1.0;
|
||||
double maxLengthFraction = 1.0;
|
||||
// sustainLevel is always [0,1] — no shell knob needed, kept implicit.
|
||||
};
|
||||
|
||||
// Which node a grab at (x, y) lands on, given the CURRENT envelope + overlay rect + sample
|
||||
// duration (the same inputs buildEnvelopePolyline drew from, so the grab tests the drawn handles).
|
||||
// Returns EnvNode::Origin's NON-membership as a miss via the bool return: `hit` is false for a
|
||||
// point off every DRAGGABLE node. Origin and ReleaseStart are never returned (not draggable). On a
|
||||
// tie (two handles within the radius) the earlier draw-order node wins (deterministic, mirroring
|
||||
// waveform_view::markerAtPoint's first-match). Pure.
|
||||
struct NodeHit {
|
||||
bool hit = false;
|
||||
EnvNode node = EnvNode::Origin; // meaningful only when hit == true
|
||||
};
|
||||
NodeHit nodeAtPoint(const AmpEnvelope& env, const Rect& area, double totalSeconds, int x, int y);
|
||||
|
||||
// Resolve a drag of `node` to a new AmpEnvelope. Given the envelope AS OF GRAB TIME (`grabEnv` —
|
||||
// the shell snapshots it on WM_LBUTTONDOWN so the delta is absolute, not accumulated), the overlay
|
||||
// rect + sample duration (the pixel↔param maps), the caller's clamp bounds, and the pixel delta
|
||||
// since grab (`dxPixels`, `dyPixels`), returns the envelope the node should now describe:
|
||||
// * X delta -> the node's TIME param, shifted proportionally (same linear map as timeToX),
|
||||
// clamped to [0, per-param max] AND to its monotonic-in-time neighbours (>= predecessor time,
|
||||
// <= successor time). For a cumulative-time node the shift lands on that node's OWN segment
|
||||
// duration (e.g. dragging HoldEnd changes holdSeconds, not attack).
|
||||
// * Y delta -> the LEVEL param, but ONLY for the sustain node (DecayEnd); clamped to [0,1].
|
||||
// dyPixels is IGNORED for every time-only node.
|
||||
// * Non-draggable node (Origin / ReleaseStart) or a zero-width/zero-height area or
|
||||
// totalSeconds <= 0 -> `grabEnv` returned unchanged (no motion).
|
||||
// Only the dragged node's param(s) change; every other field carries through from `grabEnv`. Pure
|
||||
// — rounding is to the param's continuous value (no snapping, matching the sliders' resolution).
|
||||
AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect& area,
|
||||
double totalSeconds, const EnvClampBounds& bounds,
|
||||
int dxPixels, int dyPixels);
|
||||
|
||||
} // namespace reasampler::vst
|
||||
@@ -0,0 +1,113 @@
|
||||
// envelope_overlay.cpp — see envelope_overlay.h. Pure geometry; no host types.
|
||||
|
||||
#include "envelope_overlay.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
int timeToX(const Rect& area, double totalSeconds, double t) {
|
||||
const int w = std::max(0, area.width());
|
||||
if (w <= 0 || totalSeconds <= 0.0) return area.left;
|
||||
if (t < 0.0) t = 0.0;
|
||||
// Linear map, NOT clamped on the high side: t past totalSeconds maps past area.right (the Gate
|
||||
// release tail, drawn after the sample end by design). Round to the nearest pixel.
|
||||
const double frac = t / totalSeconds;
|
||||
const long xi = static_cast<long>(frac * static_cast<double>(w) + 0.5);
|
||||
return area.left + static_cast<int>(xi);
|
||||
}
|
||||
|
||||
int levelToY(const Rect& area, double level) {
|
||||
const int h = std::max(0, area.height());
|
||||
if (h <= 0) return area.top;
|
||||
if (level < 0.0) level = 0.0;
|
||||
if (level > 1.0) level = 1.0;
|
||||
// Level 1 -> top row, level 0 -> bottom row (bottom-1 under the half-open convention). The
|
||||
// range spans (h-1) pixels so both endpoints land ON a drawable row.
|
||||
const int span = h - 1;
|
||||
const long dy = static_cast<long>((1.0 - level) * static_cast<double>(span) + 0.5);
|
||||
return area.top + static_cast<int>(dy);
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
double clamp01(double v) {
|
||||
if (v < 0.0) return 0.0;
|
||||
if (v > 1.0) return 1.0;
|
||||
return v;
|
||||
}
|
||||
|
||||
EnvVertex vtx(EnvNode node, const Rect& area, double totalSeconds, double t, double level) {
|
||||
EnvVertex v;
|
||||
v.node = node;
|
||||
v.x = timeToX(area, totalSeconds, t);
|
||||
v.y = levelToY(area, level);
|
||||
v.level = level;
|
||||
return v;
|
||||
}
|
||||
|
||||
std::vector<EnvVertex> gatePolyline(const AmpEnvelope& env, const Rect& area, double totalSeconds) {
|
||||
// Non-negative segment durations (a stored negative would be an upstream bug; clamp defensively).
|
||||
const double a = std::max(0.0, env.attackSeconds);
|
||||
const double h = std::max(0.0, env.holdSeconds);
|
||||
const double d = std::max(0.0, env.decaySeconds);
|
||||
const double r = std::max(0.0, env.releaseSeconds);
|
||||
const double sus = clamp01(env.sustainLevel);
|
||||
|
||||
// Cumulative wall-clock times of each breakpoint from t=0.
|
||||
const double tAttack = a;
|
||||
const double tHold = tAttack + h;
|
||||
const double tDecay = tHold + d;
|
||||
// The sustain plateau runs to the sample end; if the pre-sustain stages already overrun the
|
||||
// sample, the plateau collapses to zero width (its end clamps up to tDecay).
|
||||
const double tSustainEnd = std::max(tDecay, totalSeconds);
|
||||
const double tRelease = tSustainEnd + r; // release trails PAST the sample end, by design
|
||||
|
||||
std::vector<EnvVertex> pts;
|
||||
pts.reserve(6);
|
||||
pts.push_back(vtx(EnvNode::Origin, area, totalSeconds, 0.0, 0.0));
|
||||
pts.push_back(vtx(EnvNode::AttackEnd, area, totalSeconds, tAttack, 1.0));
|
||||
pts.push_back(vtx(EnvNode::HoldEnd, area, totalSeconds, tHold, 1.0));
|
||||
pts.push_back(vtx(EnvNode::DecayEnd, area, totalSeconds, tDecay, sus)); // sustain node
|
||||
pts.push_back(vtx(EnvNode::ReleaseStart, area, totalSeconds, tSustainEnd, sus)); // plateau end
|
||||
pts.push_back(vtx(EnvNode::ReleaseEnd, area, totalSeconds, tRelease, 0.0));
|
||||
return pts;
|
||||
}
|
||||
|
||||
std::vector<EnvVertex> triggerPolyline(const AmpEnvelope& env, const Rect& area,
|
||||
double totalSeconds) {
|
||||
// The played span is lengthFraction of the whole sample; fades are fractions OF that span.
|
||||
const double len = clamp01(env.lengthFraction);
|
||||
double fadeIn = clamp01(env.fadeInFraction);
|
||||
double fadeOut = clamp01(env.fadeOutFraction);
|
||||
// Fades cannot overlap: clamp so fadeIn + fadeOut <= 1 (of the played span), mirroring the
|
||||
// engine's TriggerParams clamp. Trim the LATER fade (fade-out) first, matching the engine.
|
||||
if (fadeIn + fadeOut > 1.0) fadeOut = std::max(0.0, 1.0 - fadeIn);
|
||||
|
||||
const double playSeconds = len * totalSeconds;
|
||||
const double tFadeInEnd = fadeIn * playSeconds;
|
||||
const double tFadeOutStart = playSeconds - fadeOut * playSeconds; // where fade-out begins
|
||||
|
||||
std::vector<EnvVertex> pts;
|
||||
pts.reserve(4);
|
||||
pts.push_back(vtx(EnvNode::Origin, area, totalSeconds, 0.0, 0.0));
|
||||
pts.push_back(vtx(EnvNode::FadeInEnd, area, totalSeconds, tFadeInEnd, 1.0));
|
||||
pts.push_back(vtx(EnvNode::FadeOutStart, area, totalSeconds, tFadeOutStart, 1.0)); // unity end
|
||||
pts.push_back(vtx(EnvNode::LengthEnd, area, totalSeconds, playSeconds, 0.0)); // playEnd
|
||||
return pts;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::vector<EnvVertex> buildEnvelopePolyline(const AmpEnvelope& env, const Rect& area,
|
||||
double totalSeconds) {
|
||||
if (area.width() <= 0 || area.height() <= 0 || totalSeconds <= 0.0) {
|
||||
// Degenerate surface: a two-point flat baseline at level 0 so the shell always has a line.
|
||||
return {vtx(EnvNode::Origin, area, 1.0, 0.0, 0.0),
|
||||
vtx(EnvNode::ReleaseEnd, area, 1.0, 1.0, 0.0)};
|
||||
}
|
||||
return env.mode == EnvMode::Gate ? gatePolyline(env, area, totalSeconds)
|
||||
: triggerPolyline(env, area, totalSeconds);
|
||||
}
|
||||
|
||||
} // namespace reasampler::vst
|
||||
@@ -0,0 +1,162 @@
|
||||
// envelope_overlay.h — PURE amp-envelope → polyline geometry for the S-VIEW-3 Sample-view
|
||||
// envelope overlay. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror of
|
||||
// waveform_view / param_slider: the params→pixel polyline math lives here, unit-tested outside
|
||||
// the DAW, while the editor shell (reasampler_editor.cpp) traces the polyline in an accent hue
|
||||
// and draws the node handles (via envelope_edit's hit-test).
|
||||
//
|
||||
// WHAT IT DRAWS. The amp envelope over the Sample view's hero waveform at accurate wall-clock
|
||||
// time (Simpler / Phase-Plant grammar):
|
||||
// * Gate -> the AHDSR shape: attack ramp 0->1, hold plateau at 1, decay 1->sustain,
|
||||
// sustain plateau, release sustain->0.
|
||||
// * Trigger -> the fade/%-length shape: fade-in 0->1, unity plateau, fade-out 1->0 anchored
|
||||
// to playEnd (= lengthFraction of the post-start span).
|
||||
// The horizontal axis is wall-clock TIME across the waveform rect; the vertical axis is LEVEL
|
||||
// (0 at rect bottom, 1 at rect top). The overlay shares the waveform's time base so the drawn
|
||||
// shape lines up with the PCM under it: the same [0, frameCount] span waveform_view maps, so the
|
||||
// envelope's own duration is placed at the SAME frames the voice plays it over.
|
||||
//
|
||||
// DELIBERATELY ENGINE-FREE (house pattern — param_slider does the same). It does NOT depend on
|
||||
// sample_map / sampler_core (which would drag bank_book / wav_trim in). The shell reads the
|
||||
// zone's AdsrSeconds / TriggerParams and packs them into the small AmpEnvelope view struct here.
|
||||
// AHDSR times are wall-clock SECONDS (rate-free, matching the stored domain — Daniel's no-
|
||||
// hardcoded-rate ruling); Trigger fades are FRACTIONS of the play span. The one rate-bound input
|
||||
// is the total sample duration in seconds, which the shell resolves once from the live rate and
|
||||
// the frame count and passes in — this module never sees a sample rate.
|
||||
//
|
||||
// It reuses editor_geometry's Rect + contains(), the one shared geometry idiom.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "editor_geometry.h" // Rect — the shared geometry idiom
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
// The play mode the overlay draws — a LOCAL mirror of sampler_core's PlayMode kept here so the
|
||||
// geometry module stays engine-free (the shell maps the zone's PlayMode to this). Same two cases.
|
||||
enum class EnvMode { Gate, Trigger };
|
||||
|
||||
// Which breakpoint a polyline vertex / node is. The shell draws a draggable handle at each of
|
||||
// these; envelope_edit hit-tests against them. Kept in one enum shared by overlay + edit so the
|
||||
// forward map (draw) and inverse map (edit) name the same nodes.
|
||||
//
|
||||
// Gate nodes: Origin -> AttackEnd -> HoldEnd -> DecayEnd(=sustain corner) -> ReleaseStart
|
||||
// -> ReleaseEnd. The sustain node is DecayEnd (its Y is the sustain level);
|
||||
// ReleaseStart is a drawing-only plateau-end vertex.
|
||||
// Trigger nodes: Origin -> FadeInEnd -> FadeOutStart -> LengthEnd(playEnd, level 0). The fade-out
|
||||
// ramp is the FadeOutStart->LengthEnd segment; LengthEnd is the playEnd terminal.
|
||||
enum class EnvNode {
|
||||
Origin, // t=0, level 0 (both modes) — not draggable (fixed anchor)
|
||||
AttackEnd, // Gate: top of the attack ramp (level 1) — X sets attackSeconds
|
||||
HoldEnd, // Gate: end of the hold plateau (level 1) — X sets holdSeconds
|
||||
DecayEnd, // Gate: decay settles to sustain — the SUSTAIN node (X sets decaySeconds,
|
||||
// Y sets sustainLevel)
|
||||
ReleaseStart, // Gate: end of the sustain plateau / start of the release (sustain level) —
|
||||
// a DRAWING vertex only, not a draggable handle (release is edited at
|
||||
// ReleaseEnd; this vertex tracks its X = sample end, Y = sustain level)
|
||||
ReleaseEnd, // Gate: end of the release tail (level 0) — X sets releaseSeconds
|
||||
FadeInEnd, // Trigger: top of the fade-in (level 1) — X sets fadeInFraction
|
||||
FadeOutStart, // Trigger: end of the unity plateau / start of the fade-out (level 1) —
|
||||
// X sets fadeOutFraction
|
||||
LengthEnd, // Trigger: the playEnd terminal / %-length (level 0) — X sets lengthFraction
|
||||
};
|
||||
|
||||
// The amp-envelope params the overlay draws — the small view struct the shell packs from the
|
||||
// zone's stored AdsrSeconds / TriggerParams. Engine-free by design (no sampler_core include).
|
||||
//
|
||||
// Gate fields (SECONDS, wall-clock): attack / hold / decay / release; sustain is a LEVEL 0..1.
|
||||
// These map 1-to-1 with the stored AdsrSeconds fields — no conversion required.
|
||||
//
|
||||
// Trigger fields (FRACTIONS of play): fadeIn / fadeOut as a fraction of the played span;
|
||||
// lengthFraction is the played span as a fraction of the
|
||||
// post-start sample length (matching TriggerParams).
|
||||
//
|
||||
// TRIGGER SEAM — CONVERSION REQUIRED ON BOTH PATHS (Wave 2 shell author, read this):
|
||||
// TriggerParams (sampler_core.h) stores Trigger fades as SOURCE FRAMES:
|
||||
// fadeInFrames (int64_t) — 0->1 ramp length in source frames
|
||||
// fadeOutFrames (int64_t) — 1->0 ramp length in source frames
|
||||
// AmpEnvelope stores them as FRACTIONS of the played span:
|
||||
// fadeInFraction = fadeInFrames / playLengthFrames
|
||||
// fadeOutFraction = fadeOutFrames / playLengthFrames
|
||||
// where playLengthFrames = round(lengthFraction * (frameCount - startFrame)).
|
||||
// This is a NON-TRIVIAL derived view — NOT a direct field copy. The shell owes a
|
||||
// converter on BOTH directions:
|
||||
// PACK (draw): frames -> fraction (TriggerParams -> AmpEnvelope, needs frameCount + rate)
|
||||
// UNPACK (commit): fraction -> frames (AmpEnvelope -> TriggerParams, same inputs)
|
||||
// lengthFraction maps 1-to-1 with TriggerParams::lengthFraction and needs no conversion.
|
||||
//
|
||||
// Unused fields for the active mode are ignored.
|
||||
struct AmpEnvelope {
|
||||
EnvMode mode = EnvMode::Gate;
|
||||
|
||||
// Gate (AHDSR), seconds + a dimensionless sustain level.
|
||||
double attackSeconds = 0.003;
|
||||
double holdSeconds = 0.0;
|
||||
double decaySeconds = 0.0;
|
||||
double sustainLevel = 1.0;
|
||||
double releaseSeconds = 0.060;
|
||||
|
||||
// Trigger, fractions of the play span (fadeIn/fadeOut) and of the post-start length.
|
||||
// NOTE: fadeInFraction/fadeOutFraction are DERIVED from TriggerParams::fadeInFrames/
|
||||
// fadeOutFrames — see the TRIGGER SEAM note above. A converter is owed on both the
|
||||
// pack (draw) and unpack (commit) paths; these fields are NOT a direct TriggerParams copy.
|
||||
double lengthFraction = 1.0; // (0,1] of the post-start span that plays (1-to-1 with TriggerParams)
|
||||
double fadeInFraction = 0.0; // 0->1 ramp as a fraction of the played span (DERIVED — see above)
|
||||
double fadeOutFraction = 0.0; // 1->0 ramp as a fraction of the played span (DERIVED — see above)
|
||||
};
|
||||
|
||||
// One polyline vertex: a pixel point plus which node it is. The shell draws a line through the
|
||||
// points in order (the amp curve) and a draggable handle at each vertex whose node is not Origin.
|
||||
// Level is carried alongside (0..1) for callers that want to label/inspect; it is redundant with y.
|
||||
struct EnvVertex {
|
||||
EnvNode node = EnvNode::Origin;
|
||||
int x = 0; // pixel x inside the overlay rect
|
||||
int y = 0; // pixel y inside the overlay rect (top = level 1, bottom = level 0)
|
||||
double level = 0.0; // 0..1, the vertex's amplitude (redundant with y; for inspection)
|
||||
|
||||
bool operator==(const EnvVertex& o) const {
|
||||
return node == o.node && x == o.x && y == o.y && level == o.level;
|
||||
}
|
||||
};
|
||||
|
||||
// Map an amp envelope to its polyline vertices inside `area`, over a sample of `totalSeconds`
|
||||
// wall-clock duration. `area` is the waveform rect (left/top inclusive, right/bottom exclusive);
|
||||
// x maps time 0..totalSeconds across [area.left, area.right], y maps level 0..1 across
|
||||
// [area.bottom-1 .. area.top] (level 1 at the TOP). The polyline reads left-to-right in draw
|
||||
// order, Origin first.
|
||||
//
|
||||
// TIME BASE. The envelope's own segment durations are placed on the SAME time axis the waveform
|
||||
// occupies, so the curve lines up with the PCM:
|
||||
// * Gate: attack/hold/decay run from t=0; the sustain plateau runs to the note-off. Since the
|
||||
// overlay has no held note-off to draw against, the sustain plateau is drawn to the END of
|
||||
// the sample (totalSeconds) and the release tail is drawn AFTER that boundary — i.e. the
|
||||
// release is appended past the sample end (the standard "release after key-up at end of
|
||||
// view" convention). When attack+hold+decay already exceed totalSeconds the plateau collapses
|
||||
// to zero width (nodes clamp to the sample end) and release still trails past it.
|
||||
// * Trigger: the played span is lengthFraction * totalSeconds; fade-in/out are fractions OF
|
||||
// that played span. Nodes past the played span never appear (LengthEnd/FadeOutEnd sit at the
|
||||
// played span's right edge).
|
||||
//
|
||||
// A time beyond totalSeconds (the Gate release tail) maps past area.right — the shell clips at
|
||||
// paint time (the same way waveform_view lets a frame past the count pin the marker). A degenerate
|
||||
// area (zero width/height) or totalSeconds <= 0 yields the two-point flat baseline [Origin, end at
|
||||
// level 0] so the shell always has a drawable line. Pure — same inputs, same polyline.
|
||||
std::vector<EnvVertex> buildEnvelopePolyline(const AmpEnvelope& env, const Rect& area,
|
||||
double totalSeconds);
|
||||
|
||||
// Map a time (seconds) to a pixel x inside `area`: t=0 -> area.left, t=totalSeconds -> area.right,
|
||||
// linear. t is NOT clamped on the high side (a Gate release past the sample end maps past
|
||||
// area.right, by design — see buildEnvelopePolyline); t < 0 pins to area.left. A zero-width area
|
||||
// or totalSeconds <= 0 yields area.left. Pure — the shared time->x map both the polyline and the
|
||||
// node hit-test (envelope_edit) use, so the drawn handle and its grab region agree.
|
||||
int timeToX(const Rect& area, double totalSeconds, double t);
|
||||
|
||||
// Map a level (0..1) to a pixel y inside `area`: level 1 -> area.top, level 0 -> area.bottom-1
|
||||
// (so the full-amplitude line sits at the top edge and silence at the bottom pixel row). level is
|
||||
// clamped to [0,1]. A zero-height area yields area.top. Pure — the shared level->y map the polyline
|
||||
// and the node hit-test share.
|
||||
int levelToY(const Rect& area, double level);
|
||||
|
||||
} // namespace reasampler::vst
|
||||
@@ -0,0 +1,248 @@
|
||||
// Standalone tests for reasampler::vst::envelope_edit — no VST3, no REAPER, no framework.
|
||||
// Same fast assert loop as the sibling pure tests. Assert the S-VIEW-3 draggable-node INVERSE
|
||||
// map: node hit-test + pixel-delta -> clamped/monotonic param set, HARD at the clamp + monotonic
|
||||
// boundaries (the load-bearing "a drag can never produce a param a slider couldn't" invariant).
|
||||
//
|
||||
// Covers: nodeAtPoint (grabs a drawn handle within the pick radius; misses off every node; skips
|
||||
// the non-draggable Origin/ReleaseStart anchors; first-match determinism); resolveNodeDrag Gate
|
||||
// (each cumulative node edits its OWN segment; X->time, sustain node's Y->level; lower clamp at 0;
|
||||
// upper clamp at the caller's max; only the dragged param changes); resolveNodeDrag Trigger (fades
|
||||
// as fractions of the played span; fadeIn/fadeOut mutual clamp so they never cross; length clamp;
|
||||
// FadeOutStart moves OPPOSITE the pixel delta); degenerate area/duration + non-draggable node ->
|
||||
// no motion.
|
||||
|
||||
#include "../src/vst/envelope_edit.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
|
||||
using namespace reasampler::vst;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
static bool near(double a, double b, double eps = 1e-9) { return std::fabs(a - b) <= eps; }
|
||||
|
||||
// 1000px wide, 100px tall, offset origin. 2.0s total => 500 px/s => 0.002 s/px.
|
||||
static Rect wideArea() { return Rect{20, 10, 1020, 110}; }
|
||||
static constexpr double kTotal = 2.0;
|
||||
|
||||
static AmpEnvelope gateEnv() {
|
||||
AmpEnvelope e;
|
||||
e.mode = EnvMode::Gate;
|
||||
e.attackSeconds = 0.2;
|
||||
e.holdSeconds = 0.1;
|
||||
e.decaySeconds = 0.3;
|
||||
e.sustainLevel = 0.5;
|
||||
e.releaseSeconds = 0.4;
|
||||
return e;
|
||||
}
|
||||
|
||||
static AmpEnvelope triggerEnv() {
|
||||
AmpEnvelope e;
|
||||
e.mode = EnvMode::Trigger;
|
||||
e.lengthFraction = 0.5; // played span 1.0s -> 500px
|
||||
e.fadeInFraction = 0.2;
|
||||
e.fadeOutFraction = 0.2;
|
||||
return e;
|
||||
}
|
||||
|
||||
// --- nodeAtPoint --------------------------------------------------------------
|
||||
|
||||
static void testHitGrabsDrawnHandle() {
|
||||
const AmpEnvelope e = gateEnv();
|
||||
const Rect a = wideArea();
|
||||
// AttackEnd draws at x = left+100 (0.2s), y = top (level 1). A grab there hits it.
|
||||
NodeHit h = nodeAtPoint(e, a, kTotal, a.left + 100, a.top);
|
||||
CHECK(h.hit && h.node == EnvNode::AttackEnd);
|
||||
// The sustain node (DecayEnd) at 0.6s -> left+300, level 0.5 -> ~top+50.
|
||||
NodeHit s = nodeAtPoint(e, a, kTotal, a.left + 300, a.top + 50);
|
||||
CHECK(s.hit && s.node == EnvNode::DecayEnd);
|
||||
}
|
||||
|
||||
static void testHitMissesOffEveryNode() {
|
||||
const AmpEnvelope e = gateEnv();
|
||||
const Rect a = wideArea();
|
||||
// A point far from any drawn handle (mid plateau, well away from a node).
|
||||
NodeHit h = nodeAtPoint(e, a, kTotal, a.left + 700, a.top + 5);
|
||||
CHECK(!h.hit);
|
||||
}
|
||||
|
||||
static void testHitSkipsNonDraggableAnchors() {
|
||||
const AmpEnvelope e = gateEnv();
|
||||
const Rect a = wideArea();
|
||||
// Origin draws at (left, bottom-1). Even a pixel-perfect grab there is NOT a draggable node.
|
||||
NodeHit o = nodeAtPoint(e, a, kTotal, a.left, a.bottom - 1);
|
||||
CHECK(!o.hit);
|
||||
// ReleaseStart draws at (right, sustain level). It is drawing-only -> not grabbable. But
|
||||
// ReleaseEnd is elsewhere, so a grab exactly at ReleaseStart's point must miss.
|
||||
// ReleaseStart x == right (plateau to sample end), y == sustain (~top+50).
|
||||
NodeHit rs = nodeAtPoint(e, a, kTotal, a.right, a.top + 50);
|
||||
CHECK(!rs.hit);
|
||||
}
|
||||
|
||||
// --- resolveNodeDrag Gate -----------------------------------------------------
|
||||
|
||||
static void testGateAttackDragMovesOnlyAttack() {
|
||||
const AmpEnvelope e = gateEnv();
|
||||
const Rect a = wideArea();
|
||||
EnvClampBounds b; // default maxima 4.0s
|
||||
// +50px at 0.002 s/px = +0.1s on attack (0.2 -> 0.3). Nothing else moves.
|
||||
AmpEnvelope out = resolveNodeDrag(e, EnvNode::AttackEnd, a, kTotal, b, 50, 0);
|
||||
CHECK(near(out.attackSeconds, 0.3));
|
||||
CHECK(near(out.holdSeconds, e.holdSeconds));
|
||||
CHECK(near(out.decaySeconds, e.decaySeconds));
|
||||
CHECK(near(out.sustainLevel, e.sustainLevel));
|
||||
CHECK(near(out.releaseSeconds, e.releaseSeconds));
|
||||
}
|
||||
|
||||
static void testGateTimeLowerClampAtZero() {
|
||||
const AmpEnvelope e = gateEnv();
|
||||
const Rect a = wideArea();
|
||||
EnvClampBounds b;
|
||||
// Drag attack far LEFT (-500px = -1.0s) from 0.2s: clamps to 0, never negative (monotonic:
|
||||
// the segment cannot go below zero).
|
||||
AmpEnvelope out = resolveNodeDrag(e, EnvNode::AttackEnd, a, kTotal, b, -500, 0);
|
||||
CHECK(near(out.attackSeconds, 0.0));
|
||||
}
|
||||
|
||||
static void testGateTimeUpperClampAtSliderMax() {
|
||||
const AmpEnvelope e = gateEnv();
|
||||
const Rect a = wideArea();
|
||||
EnvClampBounds b;
|
||||
b.maxDecaySeconds = 1.0; // the shell's decay slider tops out at 1.0s
|
||||
// Drag decay far RIGHT (+2000px = +4.0s) from 0.3s: clamps to the slider max 1.0, NOT beyond
|
||||
// (the drag can't produce a param the slider couldn't).
|
||||
AmpEnvelope out = resolveNodeDrag(e, EnvNode::DecayEnd, a, kTotal, b, 2000, 0);
|
||||
CHECK(near(out.decaySeconds, 1.0));
|
||||
}
|
||||
|
||||
static void testGateSustainNodeBothAxes() {
|
||||
const AmpEnvelope e = gateEnv();
|
||||
const Rect a = wideArea();
|
||||
EnvClampBounds b;
|
||||
// DecayEnd: +100px X = +0.2s decay (0.3 -> 0.5); +bottom-ward Y LOWERS the level. Level span is
|
||||
// 99 px for [0,1]; drag DOWN by ~10px (positive dy) lowers sustain by ~10/99 ~= 0.101.
|
||||
AmpEnvelope out = resolveNodeDrag(e, EnvNode::DecayEnd, a, kTotal, b, 100, 10);
|
||||
CHECK(near(out.decaySeconds, 0.5));
|
||||
CHECK(out.sustainLevel < e.sustainLevel); // dragged DOWN -> lower sustain
|
||||
CHECK(near(out.sustainLevel, 0.5 - 10.0 / 99.0, 1e-6));
|
||||
}
|
||||
|
||||
static void testGateSustainLevelClamps01() {
|
||||
const AmpEnvelope e = gateEnv();
|
||||
const Rect a = wideArea();
|
||||
EnvClampBounds b;
|
||||
// Drag sustain UP hard (dy very negative): clamps to 1.0.
|
||||
AmpEnvelope up = resolveNodeDrag(e, EnvNode::DecayEnd, a, kTotal, b, 0, -10000);
|
||||
CHECK(near(up.sustainLevel, 1.0));
|
||||
// Drag sustain DOWN hard (dy very positive): clamps to 0.0.
|
||||
AmpEnvelope dn = resolveNodeDrag(e, EnvNode::DecayEnd, a, kTotal, b, 0, 10000);
|
||||
CHECK(near(dn.sustainLevel, 0.0));
|
||||
}
|
||||
|
||||
static void testGateTimeOnlyNodeIgnoresY() {
|
||||
const AmpEnvelope e = gateEnv();
|
||||
const Rect a = wideArea();
|
||||
EnvClampBounds b;
|
||||
// HoldEnd is time-only: a big Y delta must NOT change any level (there is no level to change).
|
||||
AmpEnvelope out = resolveNodeDrag(e, EnvNode::HoldEnd, a, kTotal, b, 0, 500);
|
||||
CHECK(near(out.holdSeconds, e.holdSeconds)); // dx 0 -> no time change either
|
||||
CHECK(near(out.sustainLevel, e.sustainLevel)); // Y ignored for a time-only node
|
||||
}
|
||||
|
||||
// --- resolveNodeDrag Trigger --------------------------------------------------
|
||||
|
||||
static void testTriggerFadeInIsFractionOfPlaySpan() {
|
||||
const AmpEnvelope e = triggerEnv(); // played span 1.0s -> 500px
|
||||
const Rect a = wideArea();
|
||||
EnvClampBounds b;
|
||||
// +50px = +0.1s on the play timeline = +0.1/1.0 = +0.1 fraction. fadeIn 0.2 -> 0.3.
|
||||
AmpEnvelope out = resolveNodeDrag(e, EnvNode::FadeInEnd, a, kTotal, b, 50, 0);
|
||||
CHECK(near(out.fadeInFraction, 0.3));
|
||||
CHECK(near(out.fadeOutFraction, e.fadeOutFraction)); // unchanged
|
||||
}
|
||||
|
||||
static void testTriggerFadesCannotCross() {
|
||||
AmpEnvelope e = triggerEnv();
|
||||
e.fadeInFraction = 0.5;
|
||||
e.fadeOutFraction = 0.3; // sum 0.8, room 0.2 before they'd cross
|
||||
const Rect a = wideArea();
|
||||
EnvClampBounds b;
|
||||
// Drag fade-in far RIGHT (+2000px): would push fadeIn well past 1-fadeOut=0.7, but the mutual
|
||||
// clamp caps it at 0.7 so the fade nodes never cross (monotonic on the play timeline).
|
||||
AmpEnvelope out = resolveNodeDrag(e, EnvNode::FadeInEnd, a, kTotal, b, 2000, 0);
|
||||
CHECK(near(out.fadeInFraction, 0.7));
|
||||
CHECK(near(out.fadeOutFraction, 0.3));
|
||||
}
|
||||
|
||||
static void testTriggerFadeOutMovesOppositePixelDelta() {
|
||||
const AmpEnvelope e = triggerEnv(); // fadeOut 0.2, play span 1.0s -> 500px
|
||||
const Rect a = wideArea();
|
||||
EnvClampBounds b;
|
||||
// FadeOutStart sits at (1-fadeOut) of the span; dragging it LEFT (-50px) LENGTHENS the fade-out.
|
||||
// -50px = -0.1s = -0.1 fraction on the span, applied OPPOSITE -> fadeOut 0.2 -> 0.3.
|
||||
AmpEnvelope out = resolveNodeDrag(e, EnvNode::FadeOutStart, a, kTotal, b, -50, 0);
|
||||
CHECK(near(out.fadeOutFraction, 0.3));
|
||||
CHECK(near(out.fadeInFraction, e.fadeInFraction));
|
||||
}
|
||||
|
||||
static void testTriggerLengthClampsAtMax() {
|
||||
const AmpEnvelope e = triggerEnv(); // length 0.5
|
||||
const Rect a = wideArea();
|
||||
EnvClampBounds b; // maxLengthFraction 1.0
|
||||
// LengthEnd maps to a fraction of the WHOLE sample: +2000px = +4.0s = +2.0 fraction, clamps 1.0.
|
||||
AmpEnvelope out = resolveNodeDrag(e, EnvNode::LengthEnd, a, kTotal, b, 2000, 0);
|
||||
CHECK(near(out.lengthFraction, 1.0));
|
||||
// Drag far LEFT clamps to 0.
|
||||
AmpEnvelope lo = resolveNodeDrag(e, EnvNode::LengthEnd, a, kTotal, b, -2000, 0);
|
||||
CHECK(near(lo.lengthFraction, 0.0));
|
||||
}
|
||||
|
||||
// --- No-motion guards ---------------------------------------------------------
|
||||
|
||||
static void testNonDraggableNodeNoMotion() {
|
||||
const AmpEnvelope e = gateEnv();
|
||||
const Rect a = wideArea();
|
||||
EnvClampBounds b;
|
||||
AmpEnvelope o = resolveNodeDrag(e, EnvNode::Origin, a, kTotal, b, 500, 500);
|
||||
CHECK(near(o.attackSeconds, e.attackSeconds) && near(o.sustainLevel, e.sustainLevel));
|
||||
AmpEnvelope rs = resolveNodeDrag(e, EnvNode::ReleaseStart, a, kTotal, b, 500, 500);
|
||||
CHECK(near(rs.releaseSeconds, e.releaseSeconds));
|
||||
}
|
||||
|
||||
static void testDegenerateAreaNoMotion() {
|
||||
const AmpEnvelope e = gateEnv();
|
||||
EnvClampBounds b;
|
||||
const Rect zeroW = Rect{0, 0, 0, 100};
|
||||
AmpEnvelope o1 = resolveNodeDrag(e, EnvNode::AttackEnd, zeroW, kTotal, b, 500, 0);
|
||||
CHECK(near(o1.attackSeconds, e.attackSeconds));
|
||||
AmpEnvelope o2 = resolveNodeDrag(e, EnvNode::AttackEnd, wideArea(), 0.0, b, 500, 0); // no time
|
||||
CHECK(near(o2.attackSeconds, e.attackSeconds));
|
||||
}
|
||||
|
||||
int main() {
|
||||
testHitGrabsDrawnHandle();
|
||||
testHitMissesOffEveryNode();
|
||||
testHitSkipsNonDraggableAnchors();
|
||||
|
||||
testGateAttackDragMovesOnlyAttack();
|
||||
testGateTimeLowerClampAtZero();
|
||||
testGateTimeUpperClampAtSliderMax();
|
||||
testGateSustainNodeBothAxes();
|
||||
testGateSustainLevelClamps01();
|
||||
testGateTimeOnlyNodeIgnoresY();
|
||||
|
||||
testTriggerFadeInIsFractionOfPlaySpan();
|
||||
testTriggerFadesCannotCross();
|
||||
testTriggerFadeOutMovesOppositePixelDelta();
|
||||
testTriggerLengthClampsAtMax();
|
||||
|
||||
testNonDraggableNodeNoMotion();
|
||||
testDegenerateAreaNoMotion();
|
||||
|
||||
if (g_fail == 0) std::printf("envelope_edit: all tests passed\n");
|
||||
else std::printf("envelope_edit: %d FAILED\n", g_fail);
|
||||
return g_fail == 0 ? 0 : 1;
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
// Standalone tests for reasampler::vst::envelope_overlay — no VST3, no REAPER, no framework.
|
||||
// Same fast assert loop as the sibling pure tests. Assert the S-VIEW-3 amp-envelope -> polyline
|
||||
// FORWARD map: the Gate AHDSR shape (attack ramp / hold plateau / decay-to-sustain / plateau /
|
||||
// release) and the Trigger fade/%-length shape, at the waveform time base (so the drawn curve
|
||||
// lines up with the PCM under it).
|
||||
//
|
||||
// Covers: timeToX / levelToY (linear maps, edge clamps, release-past-end NOT clamped, degenerate
|
||||
// area/duration); buildEnvelopePolyline Gate (node order, levels, cumulative time placement,
|
||||
// sustain plateau to sample end, release past end, collapsed plateau when stages overrun);
|
||||
// buildEnvelopePolyline Trigger (fade-in/unity/fade-out at fractions of the played span, overlap
|
||||
// clamp); degenerate flat baseline.
|
||||
|
||||
#include "../src/vst/envelope_overlay.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <vector>
|
||||
|
||||
using namespace reasampler::vst;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
// A comfortable overlay area: 1000px wide, 100px tall, offset so left/top != 0 (catches origin
|
||||
// bugs). Under levelToY the level span is height-1 = 99 rows.
|
||||
static Rect wideArea() { return Rect{20, 10, 1020, 110}; } // width 1000, height 100
|
||||
|
||||
// Find the first vertex with a given node in a polyline; asserts presence via the returned bool.
|
||||
static bool findNode(const std::vector<EnvVertex>& poly, EnvNode node, EnvVertex& out) {
|
||||
for (const EnvVertex& v : poly) {
|
||||
if (v.node == node) { out = v; return true; }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- timeToX / levelToY -------------------------------------------------------
|
||||
|
||||
static void testTimeToXEndpoints() {
|
||||
const Rect a = wideArea();
|
||||
CHECK(timeToX(a, 2.0, 0.0) == a.left); // t=0 -> left
|
||||
CHECK(timeToX(a, 2.0, 2.0) == a.right); // t=total -> right
|
||||
CHECK(timeToX(a, 2.0, 1.0) == a.left + 500); // midpoint
|
||||
}
|
||||
|
||||
static void testTimeToXNegativePinsLeft() {
|
||||
const Rect a = wideArea();
|
||||
CHECK(timeToX(a, 2.0, -0.5) == a.left); // t<0 pins left
|
||||
}
|
||||
|
||||
static void testTimeToXPastEndNotClamped() {
|
||||
// The Gate release tail is drawn past the sample end BY DESIGN: t past total maps past right.
|
||||
const Rect a = wideArea();
|
||||
CHECK(timeToX(a, 2.0, 3.0) > a.right); // t=1.5x total -> past the right edge
|
||||
CHECK(timeToX(a, 2.0, 3.0) == a.left + 1500);
|
||||
}
|
||||
|
||||
static void testTimeToXDegenerate() {
|
||||
const Rect a = wideArea();
|
||||
CHECK(timeToX(a, 0.0, 1.0) == a.left); // no duration -> left
|
||||
const Rect z = Rect{5, 5, 5, 45}; // zero width
|
||||
CHECK(timeToX(z, 2.0, 1.0) == z.left);
|
||||
}
|
||||
|
||||
static void testLevelToYEndpoints() {
|
||||
const Rect a = wideArea();
|
||||
CHECK(levelToY(a, 1.0) == a.top); // level 1 -> top row
|
||||
CHECK(levelToY(a, 0.0) == a.bottom - 1); // level 0 -> bottom row
|
||||
CHECK(levelToY(a, 0.5) == a.top + 50); // mid: round((1-0.5)*99)=round(49.5)=50
|
||||
}
|
||||
|
||||
static void testLevelToYClamps() {
|
||||
const Rect a = wideArea();
|
||||
CHECK(levelToY(a, 2.0) == a.top); // >1 clamps to top
|
||||
CHECK(levelToY(a, -1.0) == a.bottom - 1); // <0 clamps to bottom
|
||||
const Rect z = Rect{5, 5, 45, 5}; // zero height
|
||||
CHECK(levelToY(z, 0.5) == z.top);
|
||||
}
|
||||
|
||||
// --- Gate polyline ------------------------------------------------------------
|
||||
|
||||
static void testGateNodeOrderAndLevels() {
|
||||
AmpEnvelope env;
|
||||
env.mode = EnvMode::Gate;
|
||||
env.attackSeconds = 0.2;
|
||||
env.holdSeconds = 0.1;
|
||||
env.decaySeconds = 0.3;
|
||||
env.sustainLevel = 0.5;
|
||||
env.releaseSeconds = 0.4;
|
||||
const Rect a = wideArea();
|
||||
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, a, 2.0);
|
||||
|
||||
// Six vertices, in draw order.
|
||||
CHECK(poly.size() == 6);
|
||||
CHECK(poly[0].node == EnvNode::Origin);
|
||||
CHECK(poly[1].node == EnvNode::AttackEnd);
|
||||
CHECK(poly[2].node == EnvNode::HoldEnd);
|
||||
CHECK(poly[3].node == EnvNode::DecayEnd);
|
||||
CHECK(poly[4].node == EnvNode::ReleaseStart);
|
||||
CHECK(poly[5].node == EnvNode::ReleaseEnd);
|
||||
|
||||
// Levels: origin 0, attack/hold peak 1, decay settles to sustain, plateau holds sustain,
|
||||
// release ends at 0.
|
||||
CHECK(poly[0].level == 0.0);
|
||||
CHECK(poly[1].level == 1.0);
|
||||
CHECK(poly[2].level == 1.0);
|
||||
CHECK(poly[3].level == 0.5); // sustain
|
||||
CHECK(poly[4].level == 0.5); // plateau end holds sustain
|
||||
CHECK(poly[5].level == 0.0);
|
||||
}
|
||||
|
||||
static void testGateCumulativeTimePlacement() {
|
||||
// total 2.0s over 1000px => 500 px/s. attack .2 -> x@100, hold end .3 -> x@150, decay end .6
|
||||
// -> x@300. Sustain plateau runs to the sample END (2.0 -> right). Release .4 trails past.
|
||||
AmpEnvelope env;
|
||||
env.mode = EnvMode::Gate;
|
||||
env.attackSeconds = 0.2;
|
||||
env.holdSeconds = 0.1; // hold end at 0.3s
|
||||
env.decaySeconds = 0.3; // decay end at 0.6s
|
||||
env.sustainLevel = 0.5;
|
||||
env.releaseSeconds = 0.4; // release end at 2.4s (past the 2.0s end)
|
||||
const Rect a = wideArea();
|
||||
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, a, 2.0);
|
||||
|
||||
EnvVertex v;
|
||||
CHECK(findNode(poly, EnvNode::AttackEnd, v) && v.x == a.left + 100);
|
||||
CHECK(findNode(poly, EnvNode::HoldEnd, v) && v.x == a.left + 150);
|
||||
CHECK(findNode(poly, EnvNode::DecayEnd, v) && v.x == a.left + 300);
|
||||
CHECK(findNode(poly, EnvNode::ReleaseStart, v) && v.x == a.right); // plateau to end
|
||||
CHECK(findNode(poly, EnvNode::ReleaseEnd, v) && v.x == a.left + 1200); // 2.4s -> 1200px past
|
||||
}
|
||||
|
||||
static void testGatePlateauCollapsesWhenStagesOverrun() {
|
||||
// A/H/D sum to 3.0s > the 2.0s sample: the plateau collapses (ReleaseStart clamps to DecayEnd's
|
||||
// time), and the release still trails past.
|
||||
AmpEnvelope env;
|
||||
env.mode = EnvMode::Gate;
|
||||
env.attackSeconds = 1.0;
|
||||
env.holdSeconds = 1.0;
|
||||
env.decaySeconds = 1.0; // decay end at 3.0s
|
||||
env.sustainLevel = 0.7;
|
||||
env.releaseSeconds = 0.5;
|
||||
const Rect a = wideArea();
|
||||
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, a, 2.0);
|
||||
|
||||
EnvVertex decay, plateauEnd, rel;
|
||||
CHECK(findNode(poly, EnvNode::DecayEnd, decay));
|
||||
CHECK(findNode(poly, EnvNode::ReleaseStart, plateauEnd));
|
||||
CHECK(findNode(poly, EnvNode::ReleaseEnd, rel));
|
||||
CHECK(plateauEnd.x == decay.x); // collapsed: plateau has zero width
|
||||
CHECK(rel.x > decay.x); // release trails past
|
||||
CHECK(plateauEnd.level == 0.7); // still at sustain
|
||||
}
|
||||
|
||||
// --- Trigger polyline ---------------------------------------------------------
|
||||
|
||||
static void testTriggerShape() {
|
||||
// played span = length * total = 0.5 * 2.0 = 1.0s -> 500px wide. fadeIn .2 of play -> 0.2s
|
||||
// (x@100), fade-out .3 of play -> begins at 0.7s (x@350), playEnd at 1.0s (x@500).
|
||||
AmpEnvelope env;
|
||||
env.mode = EnvMode::Trigger;
|
||||
env.lengthFraction = 0.5;
|
||||
env.fadeInFraction = 0.2;
|
||||
env.fadeOutFraction = 0.3;
|
||||
const Rect a = wideArea();
|
||||
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, a, 2.0);
|
||||
|
||||
CHECK(poly.size() == 4);
|
||||
CHECK(poly[0].node == EnvNode::Origin);
|
||||
CHECK(poly[1].node == EnvNode::FadeInEnd);
|
||||
CHECK(poly[2].node == EnvNode::FadeOutStart);
|
||||
CHECK(poly[3].node == EnvNode::LengthEnd);
|
||||
|
||||
EnvVertex v;
|
||||
CHECK(findNode(poly, EnvNode::FadeInEnd, v) && v.x == a.left + 100 && v.level == 1.0);
|
||||
CHECK(findNode(poly, EnvNode::FadeOutStart, v) && v.x == a.left + 350 && v.level == 1.0);
|
||||
CHECK(findNode(poly, EnvNode::LengthEnd, v) && v.x == a.left + 500 && v.level == 0.0);
|
||||
}
|
||||
|
||||
static void testTriggerFadeOverlapClamp() {
|
||||
// fadeIn + fadeOut > 1: the fade-out is trimmed so they meet exactly (no crossed nodes).
|
||||
AmpEnvelope env;
|
||||
env.mode = EnvMode::Trigger;
|
||||
env.lengthFraction = 1.0; // played span = full 2.0s -> 1000px
|
||||
env.fadeInFraction = 0.8; // fade-in end at 0.8*2.0 = 1.6s -> x@800
|
||||
env.fadeOutFraction = 0.6; // would be 1.4s -> clamped to 1-0.8=0.2 -> begins at 0.8*2.0 too
|
||||
const Rect a = wideArea();
|
||||
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, a, 2.0);
|
||||
|
||||
EnvVertex fin, fout;
|
||||
CHECK(findNode(poly, EnvNode::FadeInEnd, fin));
|
||||
CHECK(findNode(poly, EnvNode::FadeOutStart, fout));
|
||||
CHECK(fin.x == fout.x); // fades meet exactly, never cross
|
||||
CHECK(fin.x == a.left + 800);
|
||||
}
|
||||
|
||||
// --- Degenerate ---------------------------------------------------------------
|
||||
|
||||
static void testDegenerateFlatBaseline() {
|
||||
AmpEnvelope env; // any params
|
||||
const Rect zeroW = Rect{0, 0, 0, 100};
|
||||
const std::vector<EnvVertex> p1 = buildEnvelopePolyline(env, zeroW, 2.0);
|
||||
CHECK(p1.size() == 2); // always a drawable line
|
||||
CHECK(p1.front().level == 0.0 && p1.back().level == 0.0);
|
||||
|
||||
const Rect ok = wideArea();
|
||||
const std::vector<EnvVertex> p2 = buildEnvelopePolyline(env, ok, 0.0); // no duration
|
||||
CHECK(p2.size() == 2);
|
||||
CHECK(p2.front().level == 0.0 && p2.back().level == 0.0);
|
||||
CHECK(p2.front().x == ok.left && p2.back().x == ok.right); // spans the whole area flat
|
||||
}
|
||||
|
||||
int main() {
|
||||
testTimeToXEndpoints();
|
||||
testTimeToXNegativePinsLeft();
|
||||
testTimeToXPastEndNotClamped();
|
||||
testTimeToXDegenerate();
|
||||
testLevelToYEndpoints();
|
||||
testLevelToYClamps();
|
||||
|
||||
testGateNodeOrderAndLevels();
|
||||
testGateCumulativeTimePlacement();
|
||||
testGatePlateauCollapsesWhenStagesOverrun();
|
||||
|
||||
testTriggerShape();
|
||||
testTriggerFadeOverlapClamp();
|
||||
|
||||
testDegenerateFlatBaseline();
|
||||
|
||||
if (g_fail == 0) std::printf("envelope_overlay: all tests passed\n");
|
||||
else std::printf("envelope_overlay: %d FAILED\n", g_fail);
|
||||
return g_fail == 0 ? 0 : 1;
|
||||
}
|
||||
Reference in New Issue
Block a user