From 9a2b752fe90abbf1222669e3e5598f20331b3cb9 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Mon, 27 Jul 2026 13:34:43 -0400 Subject: [PATCH] docs(envelope): add Trigger frames<->fraction seam note + zero-delta guard on fade nodes --- src/vst/envelope_edit.cpp | 10 ++++++++++ src/vst/envelope_overlay.h | 26 +++++++++++++++++++++++--- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/vst/envelope_edit.cpp b/src/vst/envelope_edit.cpp index dbfcd92..a8f3e55 100644 --- a/src/vst/envelope_edit.cpp +++ b/src/vst/envelope_edit.cpp @@ -93,7 +93,16 @@ AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect // --- 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, @@ -102,6 +111,7 @@ AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect 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; diff --git a/src/vst/envelope_overlay.h b/src/vst/envelope_overlay.h index cb0bdec..0b46ff8 100644 --- a/src/vst/envelope_overlay.h +++ b/src/vst/envelope_overlay.h @@ -67,9 +67,26 @@ enum class EnvNode { // 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; @@ -82,9 +99,12 @@ struct AmpEnvelope { 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 + // 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