Merge pS-w1-t1-env: envelope_overlay + envelope_edit pure modules (S-VIEW-3 core)

This commit is contained in:
2026-07-27 13:37:26 -04:00
7 changed files with 1018 additions and 0 deletions
+31
View File
@@ -762,6 +762,24 @@ add_library(param_slider STATIC src/vst/param_slider.cpp)
target_include_directories(param_slider PUBLIC src/vst) target_include_directories(param_slider PUBLIC src/vst)
target_link_libraries(param_slider PUBLIC editor_geometry) 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) add_executable(editor_geometry_tests tests/test_editor_geometry.cpp)
target_link_libraries(editor_geometry_tests PRIVATE editor_geometry) target_link_libraries(editor_geometry_tests PRIVATE editor_geometry)
add_test(NAME editor_geometry_tests COMMAND editor_geometry_tests) 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) target_link_libraries(param_slider_tests PRIVATE param_slider)
add_test(NAME param_slider_tests COMMAND param_slider_tests) 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). # 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked).
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+138
View File
@@ -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
+94
View File
@@ -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
+113
View File
@@ -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
+162
View File
@@ -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
+248
View File
@@ -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;
}
+232
View File
@@ -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;
}