From 12a7d072acd06a598a8d77efd3c1c2b36f3d8d4b Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Mon, 27 Jul 2026 13:24:48 -0400 Subject: [PATCH] S-VIEW-3: add envelope_overlay + envelope_edit pure modules AHDSR/Trigger params -> polyline forward map (overlay) and node hit-test + clamped/monotonic pixel->param inverse map (edit) for the draggable Sample-view envelope. Engine-free, unit-tested, wired into ctest. No shell changes. --- CMakeLists.txt | 31 ++++ src/vst/envelope_edit.cpp | 128 +++++++++++++++++ src/vst/envelope_edit.h | 94 ++++++++++++ src/vst/envelope_overlay.cpp | 113 +++++++++++++++ src/vst/envelope_overlay.h | 142 ++++++++++++++++++ tests/test_envelope_edit.cpp | 248 ++++++++++++++++++++++++++++++++ tests/test_envelope_overlay.cpp | 232 ++++++++++++++++++++++++++++++ 7 files changed, 988 insertions(+) create mode 100644 src/vst/envelope_edit.cpp create mode 100644 src/vst/envelope_edit.h create mode 100644 src/vst/envelope_overlay.cpp create mode 100644 src/vst/envelope_overlay.h create mode 100644 tests/test_envelope_edit.cpp create mode 100644 tests/test_envelope_overlay.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 44e70ff..5de3a92 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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). # --------------------------------------------------------------------------- diff --git a/src/vst/envelope_edit.cpp b/src/vst/envelope_edit.cpp new file mode 100644 index 0000000..dbfcd92 --- /dev/null +++ b/src/vst/envelope_edit.cpp @@ -0,0 +1,128 @@ +// envelope_edit.cpp — see envelope_edit.h. Pure inverse map + hit-test; no host types. + +#include "envelope_edit.h" + +#include +#include // 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(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(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 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(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(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]. + case EnvNode::FadeInEnd: { + 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: { + // 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 diff --git a/src/vst/envelope_edit.h b/src/vst/envelope_edit.h new file mode 100644 index 0000000..1b26351 --- /dev/null +++ b/src/vst/envelope_edit.h @@ -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 +#include + +#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 diff --git a/src/vst/envelope_overlay.cpp b/src/vst/envelope_overlay.cpp new file mode 100644 index 0000000..6833923 --- /dev/null +++ b/src/vst/envelope_overlay.cpp @@ -0,0 +1,113 @@ +// envelope_overlay.cpp — see envelope_overlay.h. Pure geometry; no host types. + +#include "envelope_overlay.h" + +#include + +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(frac * static_cast(w) + 0.5); + return area.left + static_cast(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((1.0 - level) * static_cast(span) + 0.5); + return area.top + static_cast(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 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 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 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 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 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 diff --git a/src/vst/envelope_overlay.h b/src/vst/envelope_overlay.h new file mode 100644 index 0000000..cb0bdec --- /dev/null +++ b/src/vst/envelope_overlay.h @@ -0,0 +1,142 @@ +// 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 +#include + +#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. +// 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). +// 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. + double lengthFraction = 1.0; // (0,1] of the post-start span that plays + double fadeInFraction = 0.0; // 0->1 ramp as a fraction of the played span + double fadeOutFraction = 0.0; // 1->0 ramp as a fraction of the played span +}; + +// 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 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 diff --git a/tests/test_envelope_edit.cpp b/tests/test_envelope_edit.cpp new file mode 100644 index 0000000..a1575dc --- /dev/null +++ b/tests/test_envelope_edit.cpp @@ -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 +#include + +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; +} diff --git a/tests/test_envelope_overlay.cpp b/tests/test_envelope_overlay.cpp new file mode 100644 index 0000000..6108090 --- /dev/null +++ b/tests/test_envelope_overlay.cpp @@ -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 +#include + +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& 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 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 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 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 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 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 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 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; +}