instrument: one staged-envelope system — per-segment curves, the sustain-less AHD, and a shared overlay for all three envelopes

Trigger's fade pair folds into the AHD (and goes live); the release anchors right;
Preserve rings its synthetic tail out instead of cutting it. Payload v10.
This commit is contained in:
2026-07-31 08:37:57 -04:00
parent 87d7ceb066
commit 13e8c5c4d9
51 changed files with 3406 additions and 1812 deletions
+64 -15
View File
@@ -15,6 +15,7 @@ double deckBipolarFromNorm(double norm) { return clamp(norm, 0.0, 1.0) * 2.0 - 1
double deckNormFromBipolar(double value) { return clamp(value, -1.0, 1.0) * 0.5 + 0.5; }
std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
const bool trigger = (playMode == PlayMode::Trigger);
std::vector<DeckGroupDesc> out;
{
DeckGroupDesc pitch;
@@ -28,8 +29,10 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
DeckGroupDesc penv;
penv.id = kGroupPitchEnv;
penv.captionWidth = 58;
penv.captionRadio = {id(DeckParam::kPitchEnvSelect)};
penv.captionToggle = {id(DeckParam::kPitchEnvEnable), 32};
penv.cellIds = {id(DeckParam::kPitchEnvAttack),
id(DeckParam::kPitchEnvHold),
id(DeckParam::kPitchEnvDecay),
id(DeckParam::kPitchEnvDepth)};
out.push_back(std::move(penv));
@@ -54,27 +57,35 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
DeckGroupDesc fenv;
fenv.id = kGroupFilterEnv;
fenv.captionWidth = 66;
fenv.cellIds = {id(DeckParam::kFilterEnvAttack),
id(DeckParam::kFilterEnvHold),
id(DeckParam::kFilterEnvDecay),
id(DeckParam::kFilterEnvSustain),
id(DeckParam::kFilterEnvRelease)};
fenv.captionRadio = {id(DeckParam::kFilterEnvSelect)};
if (trigger) {
fenv.cellIds = {id(DeckParam::kFilterTrigAttack), id(DeckParam::kFilterTrigHold),
id(DeckParam::kFilterTrigDecay), -1, -1};
} else {
fenv.cellIds = {id(DeckParam::kFilterEnvAttack),
id(DeckParam::kFilterEnvHold),
id(DeckParam::kFilterEnvDecay),
id(DeckParam::kFilterEnvSustain),
id(DeckParam::kFilterEnvRelease)};
}
out.push_back(std::move(fenv));
}
{
DeckGroupDesc amp;
amp.id = kGroupAmpEnv;
amp.captionWidth = 78;
amp.captionRadio = {id(DeckParam::kAmpEnvSelect)};
amp.captionToggle = {id(DeckParam::kPlayMode), 44};
if (playMode == PlayMode::Gate) {
if (trigger) {
// The play span first, then the AHD that shapes it, time-ordered left-to-right so
// the row reads like the drawn envelope. One blank keeps the group's width — and
// therefore its neighbours' placement — identical across a mode flip.
amp.cellIds = {id(DeckParam::kTrigLength), id(DeckParam::kTrigAttack),
id(DeckParam::kTrigHold), id(DeckParam::kTrigDecay), -1};
} else {
amp.cellIds = {id(DeckParam::kAttack), id(DeckParam::kHold),
id(DeckParam::kDecay), id(DeckParam::kSustain),
id(DeckParam::kRelease)};
} else {
// Trigger, time-ordered left-to-right (Fade In / Length % / Fade Out — matches
// the drawn envelope), plus two blanks (see knob_deck.h's blank-cell contract).
amp.cellIds = {id(DeckParam::kTrigFadeIn), id(DeckParam::kTrigLength),
id(DeckParam::kTrigFadeOut), -1, -1};
}
out.push_back(std::move(amp));
}
@@ -97,6 +108,24 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
return out;
}
DeckParam curveParamFor(DeckParam knob) {
switch (knob) {
case DeckParam::kAttack: return DeckParam::kAttackCurve;
case DeckParam::kDecay: return DeckParam::kDecayCurve;
case DeckParam::kRelease: return DeckParam::kReleaseCurve;
case DeckParam::kTrigAttack: return DeckParam::kTrigAttackCurve;
case DeckParam::kTrigDecay: return DeckParam::kTrigDecayCurve;
case DeckParam::kPitchEnvAttack: return DeckParam::kPitchEnvAttackCurve;
case DeckParam::kPitchEnvDecay: return DeckParam::kPitchEnvDecayCurve;
case DeckParam::kFilterEnvAttack: return DeckParam::kFilterEnvAttackCurve;
case DeckParam::kFilterEnvDecay: return DeckParam::kFilterEnvDecayCurve;
case DeckParam::kFilterEnvRelease: return DeckParam::kFilterEnvReleaseCurve;
case DeckParam::kFilterTrigAttack: return DeckParam::kFilterTrigAttackCurve;
case DeckParam::kFilterTrigDecay: return DeckParam::kFilterTrigDecayCurve;
default: return DeckParam::kCount;
}
}
bool isLiveDeckParam(DeckParam id) {
switch (id) {
case DeckParam::kAttack:
@@ -104,7 +133,11 @@ bool isLiveDeckParam(DeckParam id) {
case DeckParam::kDecay:
case DeckParam::kSustain:
case DeckParam::kRelease:
case DeckParam::kTrigAttack:
case DeckParam::kTrigHold:
case DeckParam::kTrigDecay:
case DeckParam::kPitchEnvAttack:
case DeckParam::kPitchEnvHold:
case DeckParam::kPitchEnvDecay:
case DeckParam::kPitchEnvDepth:
case DeckParam::kFilterMorph:
@@ -118,6 +151,21 @@ bool isLiveDeckParam(DeckParam id) {
case DeckParam::kFilterEnvDecay:
case DeckParam::kFilterEnvSustain:
case DeckParam::kFilterEnvRelease:
case DeckParam::kFilterTrigAttack:
case DeckParam::kFilterTrigHold:
case DeckParam::kFilterTrigDecay:
case DeckParam::kAttackCurve:
case DeckParam::kDecayCurve:
case DeckParam::kReleaseCurve:
case DeckParam::kTrigAttackCurve:
case DeckParam::kTrigDecayCurve:
case DeckParam::kPitchEnvAttackCurve:
case DeckParam::kPitchEnvDecayCurve:
case DeckParam::kFilterEnvAttackCurve:
case DeckParam::kFilterEnvDecayCurve:
case DeckParam::kFilterEnvReleaseCurve:
case DeckParam::kFilterTrigAttackCurve:
case DeckParam::kFilterTrigDecayCurve:
return true;
// Listed rather than defaulted so a newly added control is a COMPILE error here (the
// -Wswitch gate is GCC/Clang; MSVC's C4062 is off at this project's warning level)
@@ -125,13 +173,14 @@ bool isLiveDeckParam(DeckParam id) {
case DeckParam::kPlayMode:
case DeckParam::kPitchEngine:
case DeckParam::kTrigLength:
case DeckParam::kTrigFadeIn:
case DeckParam::kTrigFadeOut:
case DeckParam::kPitchEnvEnable:
case DeckParam::kKeyTrack:
case DeckParam::kFilterEnable:
case DeckParam::kFilterVel:
case DeckParam::kFilterLaw:
case DeckParam::kAmpEnvSelect:
case DeckParam::kPitchEnvSelect:
case DeckParam::kFilterEnvSelect:
case DeckParam::kVoiceCount:
case DeckParam::kVoiceMode:
case DeckParam::kMonoTrigger:
@@ -142,13 +191,13 @@ bool isLiveDeckParam(DeckParam id) {
return false; // unreachable for a valid enumerator; silences a warning.
}
bool liveCommitFor(LiveDragKind kind, int paramId, PlayMode playMode) {
bool liveCommitFor(LiveDragKind kind, int paramId) {
switch (kind) {
case LiveDragKind::kDeckKnob:
return paramId >= 0 && paramId < static_cast<int>(DeckParam::kCount) &&
isLiveDeckParam(static_cast<DeckParam>(paramId));
case LiveDragKind::kEnvNode:
return playMode == PlayMode::Gate;
return true;
case LiveDragKind::kOther:
return false;
}
+55 -28
View File
@@ -7,7 +7,7 @@
#include <vector>
#include "core/instrument/engine/play_params.h" // PlayMode (the AMP group's Gate/Trigger face)
#include "core/instrument/engine/play_params.h" // PlayMode (the mode-dependent group faces)
#include "core/instrument/ui/knob_deck.h" // DeckGroupDesc
namespace reasampler::instrument::ui {
@@ -17,18 +17,20 @@ namespace reasampler::instrument::ui {
enum class DeckParam {
kPlayMode = 0, // Gate | Trigger toggle
kPitchEngine, // Varispeed | Preserve toggle
kAttack, // AHDSR attack (Gate) / —
kHold, // AHDSR hold (Gate)
kDecay, // AHDSR decay (Gate)
kSustain, // AHDSR sustain (Gate)
kRelease, // AHDSR release (Gate)
kTrigLength, // Trigger %-length
kTrigFadeIn, // Trigger fade-in
kTrigFadeOut, // Trigger fade-out
kPitchEnvEnable, // AD pitch envelope on|off
kPitchEnvAttack, // AD pitch attack
kPitchEnvDecay, // AD pitch decay
kPitchEnvDepth, // AD pitch depth in +/- semitones
kAttack, // amp AHDSR attack (Gate)
kHold, // amp AHDSR hold (Gate)
kDecay, // amp AHDSR decay (Gate)
kSustain, // amp AHDSR sustain (Gate)
kRelease, // amp AHDSR release (Gate)
kTrigLength, // Trigger play span, % of the post-start length
kTrigAttack, // amp AHD attack (Trigger)
kTrigHold, // amp AHD hold, % of the span left after attack + decay
kTrigDecay, // amp AHD decay (Trigger)
kPitchEnvEnable, // AHD pitch envelope on|off
kPitchEnvAttack,
kPitchEnvHold, // % of the span left after attack + decay
kPitchEnvDecay,
kPitchEnvDepth, // AHD pitch depth in +/- semitones
kKeyTrack, // key-tracking 0..200% (lives on InstrumentParams, not PlaySeconds)
// Filter. The four control positions map through filter_params' own laws; the three
// depths are bipolar and centred at zero.
@@ -41,11 +43,32 @@ enum class DeckParam {
kFilterVel, // velocity -> cutoff, +/-100%
kFilterKeyTrack, // note -> cutoff, 0..200%
kFilterLaw, // morph law row toggle: HP-BP-LP | HP-notch-LP
kFilterEnvAttack,
kFilterEnvAttack, // filter AHDSR (Gate)
kFilterEnvHold,
kFilterEnvDecay,
kFilterEnvSustain,
kFilterEnvRelease,
kFilterTrigAttack, // filter AHD (Trigger)
kFilterTrigHold,
kFilterTrigDecay,
// Curve exponents. These never get a cell of their own — each is the INNER DIAL of the
// stage knob it shapes (see curveParamFor), which is why only sloped stages have one.
kAttackCurve,
kDecayCurve,
kReleaseCurve,
kTrigAttackCurve,
kTrigDecayCurve,
kPitchEnvAttackCurve,
kPitchEnvDecayCurve,
kFilterEnvAttackCurve,
kFilterEnvDecayCurve,
kFilterEnvReleaseCurve,
kFilterTrigAttackCurve,
kFilterTrigDecayCurve,
// Overlay selection radios — transient view state, not parameters.
kAmpEnvSelect,
kPitchEnvSelect,
kFilterEnvSelect,
// Deck-only controls: processor-side per-instance params — routed to the processor
// setters, never through the parameter set.
kVoiceCount, // polyphony bound (1..32) — a stepped knob in the VOICE group
@@ -68,17 +91,24 @@ enum DeckGroupId {
};
// The deck's groups, left to right, in SIGNAL-FLOW order: pitch -> filter -> amp, then the
// two instance-wide groups. `playMode` picks the AMP group's face, via knob_deck's blank-cell
// reservation (knob_deck.h) so a mode flip never reflows the neighbouring groups.
// two instance-wide groups. `playMode` picks the AMP and FILTER ENV groups' faces — AHDSR in
// Gate, AHD in Trigger — via knob_deck's blank-cell reservation (knob_deck.h) so a mode flip
// never reflows the neighbouring groups.
std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode);
// The curve-exponent control a stage knob's INNER DIAL edits, or kCount when the knob shapes
// no curve. THE one place the "every stage except Hold and Sustain is sloped" rule is written
// down: a knob with no entry here draws no inner dial and its inner region resolves as an
// ordinary knob grab.
DeckParam curveParamFor(DeckParam knob);
// Whether control `id` is delivered LIVE — straight to the voices that are already sounding —
// rather than through an instrument reload. The line is drawn at continuously-valued playback
// controls, so this is a routing decision at the editor's commit site rather than a property
// of any one knob; moving a control across the line is a change here and nowhere else.
//
// THE home for why each excluded control is excluded. Five continuous controls are outside the
// live set, plus every discrete toggle:
// THE home for why each excluded control is excluded. Three continuous controls are outside
// the live set, plus every discrete toggle and the overlay radios:
// - the discrete toggles (play mode, pitch engine, filter enable/law, pitch-envelope enable)
// name a different sound rather than a different setting of one;
// - the three capture-anchored overrides (root, loop span, start frame) name positions in
@@ -86,13 +116,10 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode);
// - kKeyTrack and kFilterVel feed values a voice latches at note-on by design (the pitch
// ratio and the velocity-curve result), so live delivery would retune or re-gain a note
// already struck;
// - kTrigLength resolves playEnd_, a fact about the note. kTrigFadeIn/kTrigFadeOut are pure
// amplitude shape and would be live-able in principle, but they live in `sample.play` and
// are baked into SampleData at build time — the engine rebuild copies that verbatim, so
// only a reload can deliver them without widening LiveValues. They fold into the AHD
// alongside Gate's, at which point they inherit its routing; until then they reload.
// Consequence, stated plainly: a Trigger-mode instance gets NO live delivery on its amplitude
// controls. Only the filter and pitch-envelope knobs move a sounding Trigger one-shot.
// - kTrigLength resolves playEnd_, a fact about the note, not a setting of it;
// - the overlay radios select what the editor DRAWS and reach no parameter at all.
// Both amp shapes are live: the Trigger fade pair that used to reload folded into the AHD and
// inherited its routing, so a Trigger-mode instance now tracks its amplitude knobs too.
bool isLiveDeckParam(DeckParam id);
// The editor drag kinds that can commit live, in this pure module's own vocabulary (the
@@ -102,9 +129,9 @@ enum class LiveDragKind { kOther, kDeckKnob, kEnvNode };
// Whether a drag of `kind` commits live. A deck knob is live per isLiveDeckParam (negative ids
// are the shell's processor-side sentinels and out-of-range ids are not controls, so neither
// reaches the enum); an envelope-node drag is live only in Gate, where it edits the AHDSR —
// in Trigger the same drag rewrites the play span, which is not a live control.
bool liveCommitFor(LiveDragKind kind, int paramId, PlayMode playMode);
// reaches the enum); an envelope-node drag is live in either mode, since every stage value it
// can reach — AHDSR or AHD, on any of the three envelopes — is itself live.
bool liveCommitFor(LiveDragKind kind, int paramId);
// The deck's BIPOLAR knob law: 0.5 of the knob's travel is zero depth, the ends are -1 and
// +1. Exact inverses, and exact at the centre detent (0.5 -> 0 -> 0.5), so a knob parked at
+128 -70
View File
@@ -2,11 +2,17 @@
#include "core/instrument/ui/envelope_edit.h"
#include "core/util/clamp01.h"
#include <algorithm>
#include <cstdlib> // std::abs
namespace reasampler::instrument::ui {
using util::clamp01;
using util::curveFromMidLevel;
using util::curveMidLevel;
namespace {
// Matches envelope_overlay::timeToX. Zero when the area is degenerate (no motion).
@@ -30,49 +36,84 @@ double levelPerPixel(const Rect& area) {
return 1.0 / static_cast<double>(h - 1);
}
// Origin + ReleaseStart are draw-only anchors, not grabbable.
// Origin is a draw-only anchor; so is an AHDSR's ReleaseEnd, which is pinned to the right edge
// (release is dragged from ReleaseStart instead).
bool isDraggable(EnvNode n) {
switch (n) {
case EnvNode::Origin:
case EnvNode::ReleaseStart:
case EnvNode::ReleaseEnd:
return false;
default:
return true;
}
}
// Guards the degenerate baseline's cross-mode ReleaseEnd vertex from writing releaseSeconds in
// Trigger mode (and vice versa). Applied by both the hit-test and the drag resolver.
bool nodeInMode(EnvNode n, EnvMode m) {
// Guards the degenerate baseline's cross-kind vertices, and keeps the sustain-only nodes off an
// AHD. Applied by both the hit-test and the drag resolver.
bool nodeInKind(EnvNode n, EnvKind k) {
switch (n) {
case EnvNode::AttackEnd:
case EnvNode::HoldEnd:
case EnvNode::DecayEnd:
case EnvNode::ReleaseEnd:
return m == EnvMode::Gate;
case EnvNode::FadeInEnd:
case EnvNode::FadeOutStart:
case EnvNode::LengthEnd:
return m == EnvMode::Trigger;
case EnvNode::Origin:
case EnvNode::AttackCurve:
case EnvNode::DecayCurve:
return true;
case EnvNode::ReleaseStart:
case EnvNode::ReleaseCurve:
return k == EnvKind::Ahdsr;
case EnvNode::Origin:
case EnvNode::ReleaseEnd:
return false;
}
return false;
}
// The two endpoint levels of the segment a curve knot shapes. `ok` is false when the segment
// is level (nothing a curve could express), so the drag is a no-op rather than a division.
struct SegmentLevels {
double start = 0.0;
double end = 0.0;
bool ok = false;
};
SegmentLevels segmentLevels(const StageEnvelope& env, EnvNode knot) {
const double sus = clamp01(env.sustainLevel);
SegmentLevels s;
switch (knot) {
case EnvNode::AttackCurve: s = {0.0, 1.0, true}; break;
case EnvNode::DecayCurve:
s = {1.0, env.kind == EnvKind::Ahdsr ? sus : 0.0, true};
break;
case EnvNode::ReleaseCurve: s = {sus, 0.0, true}; break;
default: return s;
}
if (s.start == s.end) s.ok = false;
return s;
}
// A knot drag: the grab-time mid-level shifted by the pixel delta, read back through
// curve_law's inverse. Both directions go through the ONE law, which is why the knot and the
// inner dial cannot express different exponents.
double curveFromKnotDrag(const StageEnvelope& grabEnv, EnvNode knot, double grabExponent,
const Rect& area, int dyPixels) {
const SegmentLevels seg = segmentLevels(grabEnv, knot);
if (!seg.ok) return grabExponent;
const double grabLevel = seg.start + (seg.end - seg.start) * curveMidLevel(grabExponent);
const double newLevel = grabLevel - static_cast<double>(dyPixels) * levelPerPixel(area);
return curveFromMidLevel((newLevel - seg.start) / (seg.end - seg.start));
}
} // namespace
NodeHit nodeAtPoint(const AmpEnvelope& env, const OverlayArea& area, double totalSeconds, int x,
int y) {
NodeHit nodeAtPoint(const StageEnvelope& env, const OverlayArea& area, double totalSeconds,
int x, int y) {
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, area, totalSeconds);
// Nearest draggable, mode-matching node within the pick radius wins (Chebyshev distance);
// ties go to the earlier draw-order node. Only matters for Trigger's zero-fade-out
// coincidence (FadeOutStart overlaps LengthEnd and wins).
// Nearest draggable, kind-matching node within the pick radius wins (Chebyshev distance);
// ties go to the earlier draw-order node. Knots are appended last, so a knot coincident
// with an endpoint handle loses — a drag there stays a time edit.
NodeHit best;
int bestDist = kNodeGrabRadius + 1;
for (const EnvVertex& v : poly) {
if (!isDraggable(v.node) || !nodeInMode(v.node, env.mode)) continue;
if (!isDraggable(v.node) || !nodeInKind(v.node, env.kind)) continue;
const int dist = std::max(std::abs(x - v.x), std::abs(y - v.y));
if (dist < bestDist) { // strict-less-than keeps ties at the earlier draw order
bestDist = dist;
@@ -82,11 +123,11 @@ NodeHit nodeAtPoint(const AmpEnvelope& env, const OverlayArea& area, double tota
return best;
}
AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const OverlayArea& area,
double totalSeconds, const EnvClampBounds& bounds,
int dxPixels, int dyPixels) {
AmpEnvelope out = grabEnv;
if (!isDraggable(node) || !nodeInMode(node, grabEnv.mode)) return out;
StageEnvelope resolveNodeDrag(const StageEnvelope& grabEnv, EnvNode node, const OverlayArea& area,
double totalSeconds, const EnvClampBounds& bounds,
int dxPixels, int dyPixels) {
StageEnvelope out = grabEnv;
if (!isDraggable(node) || !nodeInKind(node, grabEnv.kind)) return out;
const Rect& rect = area.rect;
const double secPerPx = secondsPerPixel(rect, totalSeconds);
@@ -94,63 +135,80 @@ AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Over
const double dSec = static_cast<double>(dxPixels) * secPerPx;
const double gateDSec = static_cast<double>(dxPixels) * gateSecondsPerPixel(rect);
if (grabEnv.kind == EnvKind::Ahdsr) {
switch (node) {
// Each cumulative-time node edits its own segment duration. Non-negative durations
// ARE the monotonic-in-time guarantee (a segment can never go negative, so a node
// can never cross a neighbour) — the [0, max] clamp is the whole constraint.
case EnvNode::AttackEnd:
out.attackSeconds =
std::clamp(grabEnv.attackSeconds + gateDSec, 0.0, bounds.maxAttackSeconds);
break;
case EnvNode::HoldEnd:
out.holdSeconds =
std::clamp(grabEnv.holdSeconds + gateDSec, 0.0, bounds.maxHoldSeconds);
break;
case EnvNode::DecayEnd: {
// X sets decay time, Y sets sustain level (drag down = higher y = lower level).
out.decaySeconds =
std::clamp(grabEnv.decaySeconds + gateDSec, 0.0, bounds.maxDecaySeconds);
const double dLevel = -static_cast<double>(dyPixels) * levelPerPixel(rect);
out.sustainLevel = std::clamp(grabEnv.sustainLevel + dLevel, 0.0, 1.0);
break;
}
case EnvNode::ReleaseStart:
// The release runs from this node to the anchored right edge, so dragging LEFT
// (negative dx) lengthens it — the delta enters with the opposite sign.
out.releaseSeconds =
std::clamp(grabEnv.releaseSeconds - gateDSec, 0.0, bounds.maxReleaseSeconds);
break;
case EnvNode::AttackCurve:
out.attackCurve =
curveFromKnotDrag(grabEnv, node, grabEnv.attackCurve, rect, dyPixels);
break;
case EnvNode::DecayCurve:
out.decayCurve =
curveFromKnotDrag(grabEnv, node, grabEnv.decayCurve, rect, dyPixels);
break;
case EnvNode::ReleaseCurve:
out.releaseCurve =
curveFromKnotDrag(grabEnv, node, grabEnv.releaseCurve, rect, dyPixels);
break;
default:
break;
}
return out;
}
// AHD: the x-axis is the waveform's own, so a stage node moves at 1:1 wall-clock scale.
const AhdSplit s = splitAhdSeconds(grabEnv);
switch (node) {
// Gate: each cumulative-time node edits its own segment duration. Non-negative durations
// ARE the monotonic-in-time guarantee (a segment can never go negative, so a node can
// never cross a neighbour) — the [0, max] clamp is the whole constraint.
case EnvNode::AttackEnd:
out.attackSeconds =
std::clamp(grabEnv.attackSeconds + gateDSec, 0.0, bounds.maxAttackSeconds);
std::clamp(grabEnv.attackSeconds + dSec, 0.0, bounds.maxAttackSeconds);
break;
case EnvNode::HoldEnd:
out.holdSeconds = std::clamp(grabEnv.holdSeconds + gateDSec, 0.0, bounds.maxHoldSeconds);
break;
case EnvNode::DecayEnd: {
// X sets decay time, Y sets sustain level (drag down = higher y = lower level).
out.decaySeconds = std::clamp(grabEnv.decaySeconds + gateDSec, 0.0, bounds.maxDecaySeconds);
const double lvlPerPx = levelPerPixel(rect);
const double dLevel = -static_cast<double>(dyPixels) * lvlPerPx;
out.sustainLevel = std::clamp(grabEnv.sustainLevel + dLevel, 0.0, 1.0);
case EnvNode::HoldEnd: {
// Hold is a fraction of what attack and decay left, so the node's pixel motion
// converts through that remainder. A zero remainder leaves nothing to divide by and
// nothing the drag could express.
const double rem = std::max(0.0, grabEnv.spanSeconds) - s.attack - s.decay;
if (rem <= 0.0) break;
out.holdFraction = clamp01((s.hold + dSec) / rem);
break;
}
case EnvNode::ReleaseEnd:
out.releaseSeconds =
std::clamp(grabEnv.releaseSeconds + gateDSec, 0.0, bounds.maxReleaseSeconds);
case EnvNode::DecayEnd:
out.decaySeconds =
std::clamp(grabEnv.decaySeconds + dSec, 0.0, bounds.maxDecaySeconds);
break;
// Trigger: fades + length are fractions. X pixels convert to a fraction of the played
// span (fades) or the whole sample (length). fadeIn + fadeOut <= 1 keeps the two fade
// nodes from crossing (each clamps against the other).
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 = std::clamp(grabEnv.fadeInFraction + dFrac, 0.0, std::max(0.0, hi));
case EnvNode::AttackCurve:
out.attackCurve =
curveFromKnotDrag(grabEnv, node, grabEnv.attackCurve, rect, dyPixels);
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 = std::clamp(grabEnv.fadeOutFraction + dFrac, 0.0, std::max(0.0, hi));
case EnvNode::DecayCurve:
out.decayCurve = curveFromKnotDrag(grabEnv, node, grabEnv.decayCurve, rect, dyPixels);
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 = std::clamp(grabEnv.lengthFraction + dFrac, 0.0, bounds.maxLengthFraction);
default:
break;
}
case EnvNode::Origin:
case EnvNode::ReleaseStart:
break; // unreachable (isDraggable filtered above), kept for switch exhaustiveness
}
return out;
}
+34 -37
View File
@@ -1,19 +1,21 @@
// envelope_edit.h — node hit-test + pixel-delta -> clamped-param inverse map for the draggable
// envelope nodes. Mirror of card_drag/waveform_view: drag arithmetic lives here, unit-tested
// outside the DAW; the shell draws handles, captures the grab, and feeds pixel deltas back in.
// envelope nodes and their mid-segment curve knots. Mirror of card_drag/waveform_view: drag
// arithmetic lives here, unit-tested outside the DAW; the shell draws handles, captures the
// grab, and feeds pixel deltas back in.
//
// envelope_overlay owns the params->polyline forward (draw) map; this module owns the inverse
// (edit) map + hit-test. Both read/write the same AmpEnvelope fields (shell re-reads the one
// parameter set every paint), so a node drag and a slider edit are two views on one source of
// truth.
// (edit) map + hit-test. Both read/write the same StageEnvelope fields (the shell re-reads the
// one parameter set every paint), so a node drag, a knot drag, and a knob edit are three views
// on one source of truth — structurally, not through a listener chain.
//
// A drag can never produce a param a slider couldn't: nodes are monotonic in time (clamped
// between time predecessor/successor) and range-clamped to the same per-param [min,max] the
// slider uses (EnvClampBounds, caller-supplied since those maxima live shell-side).
// A drag can never produce a param a knob couldn't: time nodes are range-clamped to the same
// per-param [min,max] the knobs enforce (EnvClampBounds, caller-supplied since those maxima
// live shell-side), and a knot resolves through curve_law's own exponent domain.
//
// Time-only nodes drag on X; DecayEnd (the sustain node) drags on both axes (X = decay time,
// Y = sustain level). Origin and the drawing-only ReleaseStart are not draggable. A node is only
// editable in its own mode (Gate nodes ignore drags in Trigger mode and vice versa).
// Time-only nodes drag on X; DecayEnd in an AHDSR drags on both axes (X = decay time, Y =
// sustain level); a curve knot drags on Y alone. Origin is never draggable, and neither is an
// AHDSR's ReleaseEnd — it is anchored to the right edge, and release is dragged from
// ReleaseStart instead. A node is only editable in its own kind.
#pragma once
@@ -21,7 +23,7 @@
#include <vector>
#include "core/instrument/ui/editor_geometry.h" // Rect
#include "core/instrument/ui/envelope_overlay.h" // EnvNode, EnvMode, AmpEnvelope, EnvVertex, timeToX/levelToY
#include "core/instrument/ui/envelope_overlay.h" // EnvNode, EnvKind, StageEnvelope, EnvVertex
namespace reasampler::instrument::ui {
@@ -29,46 +31,41 @@ namespace reasampler::instrument::ui {
// kMarkerGrabWidth.
inline constexpr int kNodeGrabRadius = 6;
// Per-param clamp bounds the shell supplies — the same maxima its sliders map [0,1] onto.
// Lower bound is always 0; the monotonic-in-time constraint tightens further at edit time.
// Defaults are placeholders; the shell overrides with its live slider domain.
// Per-param clamp bounds the shell supplies — the same maxima its knobs map [0,1] onto.
// Lower bound is always 0. Defaults are placeholders; the shell overrides with its live domain.
struct EnvClampBounds {
double maxAttackSeconds = 4.0;
double maxHoldSeconds = 4.0;
double maxDecaySeconds = 4.0;
double maxReleaseSeconds = 4.0;
double maxFadeInFraction = 1.0;
double maxFadeOutFraction = 1.0;
double maxLengthFraction = 1.0;
// sustainLevel is always [0,1] — no shell knob needed.
// sustainLevel is always [0,1] and the hold FRACTION is always [0,1] — no shell knob needed.
};
// Which node a grab at (x, y) lands on, given the current envelope/rect/duration (the same
// inputs buildEnvelopePolyline drew from). `hit` is false for a point off every draggable node;
// Origin/ReleaseStart and nodes from the other mode never hit. Nearest node within the radius
// wins (Chebyshev distance); an exact tie goes to the earlier draw-order node — this only matters
// for Trigger's zero-fade-out coincidence (FadeOutStart overlaps LengthEnd and wins, so the fade
// can be dragged open from zero). Gate nodes never coincide (forward map enforces
// kGateNodeSepPx), so every Gate handle is independently grabbable.
// inputs buildEnvelopePolyline drew from). `hit` is false for a point off every draggable node.
// Nearest node within the radius wins (Chebyshev distance); an exact tie goes to the earlier
// draw-order node, and since knots are appended last, a coincident endpoint handle wins over a
// knot rather than the drag silently becoming a curve edit.
struct NodeHit {
bool hit = false;
EnvNode node = EnvNode::Origin; // meaningful only when hit == true
};
// Takes the waveform overlay (not a lane) — see waveform_view.h's overlay contract.
NodeHit nodeAtPoint(const AmpEnvelope& env, const OverlayArea& area, double totalSeconds, int x,
int y);
NodeHit nodeAtPoint(const StageEnvelope& env, const OverlayArea& area, double totalSeconds,
int x, int y);
// Resolves a drag of `node` to a new AmpEnvelope. `grabEnv` is the envelope as of grab time (the
// shell snapshots it on button-down so the delta is absolute, not accumulated); `dxPixels`/
// `dyPixels` is the pixel delta since grab.
// * X delta -> the node's time param, shifted via the same linear map as timeToX, clamped to
// [0, per-param max] and to its monotonic-in-time neighbours.
// * Y delta -> the level param, only for DecayEnd; clamped to [0,1]. Ignored for time-only nodes.
// * A non-draggable node, an other-mode node, a zero-size area, or totalSeconds <= 0 returns
// Resolves a drag of `node` to a new StageEnvelope. `grabEnv` is the envelope as of grab time
// (the shell snapshots it on button-down so the delta is absolute, not accumulated);
// `dxPixels`/`dyPixels` is the pixel delta since grab.
// * X delta -> the node's time param, at the same scale the forward map drew it, clamped to
// [0, per-param max].
// * Y delta -> the level param (AHDSR DecayEnd's sustain) or, on a knot, the segment's curve
// exponent. Ignored for time-only nodes.
// * A non-draggable node, an other-kind node, a zero-size area, or totalSeconds <= 0 returns
// `grabEnv` unchanged.
// Only the dragged node's param(s) change. Pure.
AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const OverlayArea& area,
double totalSeconds, const EnvClampBounds& bounds,
int dxPixels, int dyPixels);
StageEnvelope resolveNodeDrag(const StageEnvelope& grabEnv, EnvNode node, const OverlayArea& area,
double totalSeconds, const EnvClampBounds& bounds,
int dxPixels, int dyPixels);
} // namespace reasampler::instrument::ui
+109 -65
View File
@@ -9,6 +9,8 @@
namespace reasampler::instrument::ui {
using util::clamp01;
using util::curveMap;
using util::curveMidLevel;
int timeToX(const Rect& area, double totalSeconds, double t) {
const int w = std::max(0, area.width);
@@ -21,20 +23,14 @@ int timeToX(const Rect& area, double totalSeconds, double t) {
return area.x + static_cast<int>(px + 0.5);
}
int gateTimedWidth(const Rect& area) {
const int w = std::max(0, area.width);
if (w <= 0) return 0;
const int sustainPx =
static_cast<int>(kGateSustainDisplayFraction * static_cast<double>(w) + 0.5);
return std::max(1, w - sustainPx);
}
double gatePxPerSecond(const Rect& area) {
const int timedW = gateTimedWidth(area);
if (timedW <= 0) return 0.0;
// Minus the four per-segment separation bases and the last in-bounds column, floored at 1.
const double usable =
std::max(1.0, static_cast<double>(timedW - 1 - 4 * kGateNodeSepPx));
const int w = std::max(0, area.width);
if (w <= 0) return 0.0;
// The four timed stages share the canvas minus their four separation bases and the last
// in-bounds column; whatever they leave IS the sustain plateau, which is why a zero release
// puts the plateau's end one separation short of the right edge rather than a fixed
// fraction of the way across.
const double usable = std::max(1.0, static_cast<double>(w - 1 - 4 * kGateNodeSepPx));
return usable / (4.0 * kGateStageMaxSeconds);
}
@@ -50,20 +46,37 @@ int levelToY(const Rect& area, double level) {
return area.y + static_cast<int>(dy);
}
AhdSplit splitAhdSeconds(const StageEnvelope& env) {
AhdSplit out;
const double span = std::max(0.0, env.spanSeconds);
double a = std::max(0.0, env.attackSeconds);
if (a > span) a = span;
double d = std::max(0.0, env.decaySeconds);
if (d > span - a) d = span - a;
const double remaining = span - a - d;
out.attack = a;
out.decay = d;
out.hold = remaining * clamp01(env.holdFraction);
out.total = out.attack + out.hold + out.decay;
return out;
}
namespace {
EnvVertex vtx(EnvNode node, const Rect& area, double totalSeconds, double t, double level) {
EnvVertex vtx(EnvNode node, const Rect& area, double totalSeconds, double t, double level,
bool knot = false) {
EnvVertex v;
v.node = node;
v.x = timeToX(area, totalSeconds, t);
v.y = levelToY(area, level);
v.level = level;
v.knot = knot;
return v;
}
// Gate works in px space (timed px + the fixed sustain-plateau reserve) rather than the plain
// timeToX map; clamps in double space before the int cast for the same overflow reason as above.
EnvVertex gateVtx(EnvNode node, const Rect& area, double px, double level) {
// The AHDSR schematic works in px space rather than the plain timeToX map; clamps in double
// space before the int cast for the same overflow reason as timeToX.
EnvVertex gateVtx(EnvNode node, const Rect& area, double px, double level, bool knot = false) {
const int w = std::max(1, area.width);
if (px < 0.0) px = 0.0;
if (px > static_cast<double>(w - 1)) px = static_cast<double>(w - 1);
@@ -72,10 +85,27 @@ EnvVertex gateVtx(EnvNode node, const Rect& area, double px, double level) {
v.x = area.x + static_cast<int>(px + 0.5);
v.y = levelToY(area, level);
v.level = level;
v.knot = knot;
return v;
}
std::vector<EnvVertex> gatePolyline(const AmpEnvelope& env, const Rect& area) {
// The knot for a segment running from `startLevel` to `endLevel`, placed at the segment's
// pixel midpoint. Its level is the curve's own value at the segment midpoint, which is what
// makes the knot's height and the inner dial two readings of one exponent.
EnvVertex knotVtx(EnvNode node, const Rect& area, int x0, int x1, double startLevel,
double endLevel, double exponent) {
const double u = curveMidLevel(exponent);
const double level = startLevel + (endLevel - startLevel) * u;
EnvVertex v;
v.node = node;
v.x = (x0 + x1) / 2;
v.y = levelToY(area, level);
v.level = level;
v.knot = true;
return v;
}
std::vector<EnvVertex> gatePolyline(const StageEnvelope& env, const Rect& area) {
// Clamp defensively — a stored negative duration would be an upstream bug.
const double a = std::max(0.0, env.attackSeconds);
const double h = std::max(0.0, env.holdSeconds);
@@ -83,73 +113,87 @@ std::vector<EnvVertex> gatePolyline(const AmpEnvelope& env, const Rect& area) {
const double r = std::max(0.0, env.releaseSeconds);
const double sus = clamp01(env.sustainLevel);
// A/H/D/R map onto the timed region at the param-domain scale, each segment getting a
// kGateNodeSepPx base so nodes never coincide even at the tier-0 zero-hold/zero-decay
// defaults. The sustain plateau is the fixed reserve between DecayEnd and ReleaseStart.
const int W = std::max(1, area.width);
const double sustainPx = static_cast<double>(W - gateTimedWidth(area));
const double sep = static_cast<double>(kGateNodeSepPx);
const double pps = gatePxPerSecond(area);
double xAttack = sep + a * pps; // AttackEnd
double xHold = xAttack + sep + h * pps; // HoldEnd
double xDecay = xHold + sep + d * pps; // DecayEnd (sustain node)
double xPlateau = xDecay + sustainPx; // ReleaseStart (schematic note-off)
double xRelease = xPlateau + sep + r * pps; // ReleaseEnd
// Overrun beyond the schematic domain compresses from the right, preserving minimum gaps so
// trailing nodes stay separated instead of piling on the last column. This re-floor only
// bites when the canvas is too narrow to hold the gaps at all — gateVtx's clamp wins then.
const double xMax = static_cast<double>(W - 1);
if (xRelease > xMax) {
xRelease = xMax;
xPlateau = std::min(xPlateau, xRelease - sep);
xDecay = std::min(xDecay, xPlateau - sustainPx);
xHold = std::min(xHold, xDecay - sep);
xAttack = std::min(xAttack, xHold - sep);
xAttack = std::max(xAttack, sep);
xHold = std::max(xHold, xAttack + sep);
xDecay = std::max(xDecay, xHold + sep);
xPlateau = std::max(xPlateau, xDecay + sustainPx);
xRelease = std::max(xRelease, xPlateau + sep);
// The release ANCHORS to the right edge: ReleaseEnd is the canvas edge and ReleaseStart —
// the sustain->release join, and the node the user drags — sits a release-length to its
// left. Everything the release does not take is the sustain plateau, so a zero release
// leaves the plateau running to within one separation of the edge.
double xAttack = sep + a * pps;
double xHold = xAttack + sep + h * pps;
double xDecay = xHold + sep + d * pps;
double xPlateau = xMax - sep - r * pps;
const double xRelease = xMax;
// Keep every node separated when the four stages together would overrun the canvas: the
// plateau holds its minimum gap from the edge, then the A/H/D chain compresses from the
// right and re-floors from the left. This only bites at the domain's extremes; gateVtx's
// own clamp wins on a canvas too narrow to hold the gaps at all.
if (xPlateau < xDecay + sep) {
if (xPlateau < 4.0 * sep) xPlateau = 4.0 * sep;
xDecay = std::min(xDecay, xPlateau - sep);
xHold = std::min(xHold, xDecay - sep);
xAttack = std::min(xAttack, xHold - sep);
xAttack = std::max(xAttack, sep);
xHold = std::max(xHold, xAttack + sep);
xDecay = std::max(xDecay, xHold + sep);
xPlateau = std::max(xPlateau, xDecay + sep);
}
std::vector<EnvVertex> pts;
pts.reserve(6);
pts.reserve(9);
pts.push_back(gateVtx(EnvNode::Origin, area, 0.0, 0.0));
pts.push_back(gateVtx(EnvNode::AttackEnd, area, xAttack, 1.0));
pts.push_back(gateVtx(EnvNode::HoldEnd, area, xHold, 1.0));
pts.push_back(gateVtx(EnvNode::DecayEnd, area, xDecay, sus)); // sustain node
pts.push_back(gateVtx(EnvNode::ReleaseStart, area, xPlateau, sus)); // plateau end
pts.push_back(gateVtx(EnvNode::ReleaseEnd, area, xRelease, 0.0));
pts.push_back(gateVtx(EnvNode::ReleaseEnd, area, xRelease, 0.0)); // anchored
// Knots ride only SLOPED stages that actually have a duration — a zero-length stage has no
// interior to place a handle in, and one there would collide with its own endpoints.
if (a > 0.0) {
pts.push_back(knotVtx(EnvNode::AttackCurve, area, pts[0].x, pts[1].x, 0.0, 1.0,
env.attackCurve));
}
if (d > 0.0) {
pts.push_back(knotVtx(EnvNode::DecayCurve, area, pts[2].x, pts[3].x, 1.0, sus,
env.decayCurve));
}
if (r > 0.0) {
pts.push_back(knotVtx(EnvNode::ReleaseCurve, area, pts[4].x, pts[5].x, sus, 0.0,
env.releaseCurve));
}
return pts;
}
std::vector<EnvVertex> triggerPolyline(const AmpEnvelope& env, const Rect& area,
double totalSeconds) {
// 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; trim fade-out first, matching the engine's TriggerParams clamp.
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> ahdPolyline(const StageEnvelope& env, const Rect& area,
double totalSeconds) {
const AhdSplit s = splitAhdSeconds(env);
const double t0 = std::max(0.0, env.originSeconds);
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
pts.reserve(6);
pts.push_back(vtx(EnvNode::Origin, area, totalSeconds, t0, 0.0));
pts.push_back(vtx(EnvNode::AttackEnd, area, totalSeconds, t0 + s.attack, 1.0));
pts.push_back(vtx(EnvNode::HoldEnd, area, totalSeconds, t0 + s.attack + s.hold, 1.0));
pts.push_back(vtx(EnvNode::DecayEnd, area, totalSeconds, t0 + s.total, 0.0));
if (s.attack > 0.0) {
pts.push_back(knotVtx(EnvNode::AttackCurve, area, pts[0].x, pts[1].x, 0.0, 1.0,
env.attackCurve));
}
if (s.decay > 0.0) {
pts.push_back(knotVtx(EnvNode::DecayCurve, area, pts[2].x, pts[3].x, 1.0, 0.0,
env.decayCurve));
}
return pts;
}
} // namespace
std::vector<EnvVertex> buildEnvelopePolyline(const AmpEnvelope& env, const OverlayArea& area,
std::vector<EnvVertex> buildEnvelopePolyline(const StageEnvelope& env, const OverlayArea& area,
double totalSeconds) {
const Rect& rect = area.rect;
if (rect.width <= 0 || rect.height <= 0 || totalSeconds <= 0.0) {
@@ -157,8 +201,8 @@ std::vector<EnvVertex> buildEnvelopePolyline(const AmpEnvelope& env, const Overl
return {vtx(EnvNode::Origin, rect, 1.0, 0.0, 0.0),
vtx(EnvNode::ReleaseEnd, rect, 1.0, 1.0, 0.0)};
}
return env.mode == EnvMode::Gate ? gatePolyline(env, rect)
: triggerPolyline(env, rect, totalSeconds);
return env.kind == EnvKind::Ahdsr ? gatePolyline(env, rect)
: ahdPolyline(env, rect, totalSeconds);
}
} // namespace reasampler::instrument::ui
+68 -51
View File
@@ -1,8 +1,7 @@
// envelope_overlay.h — amp-envelope -> polyline geometry for the Sample-view envelope overlay.
// envelope_overlay.h — staged-envelope -> polyline geometry for the Sample-view overlay.
// Engine-free by design (no sample_map/sampler_core dependency); mirror of waveform_view /
// param_slider. The shell packs the one parameter set's AdsrSeconds/TriggerParams into
// AmpEnvelope and draws the polyline plus a handle at each node (envelope_edit does the
// hit-test).
// param_slider. The shell packs whichever envelope is overlay-active into StageEnvelope and
// draws the polyline plus a handle at each node (envelope_edit does the hit-test).
#pragma once
@@ -10,92 +9,98 @@
#include <vector>
#include "core/instrument/ui/editor_geometry.h" // Rect — the shared geometry idiom
#include "core/util/curve_law.h" // the ONE per-segment curve law
namespace reasampler::instrument::ui {
// Local mirror of sampler_core's PlayMode, kept here so this module stays engine-free.
enum class EnvMode { Gate, Trigger };
// Which LAYOUT POLICY an envelope takes, decided by whether it has a sustain stage rather
// than by which processor it modulates. A gated (AHDSR) envelope right-anchors its release so
// the sustain plateau reads full-width; a sustain-less (AHD) one maps 1:1 onto the waveform's
// own time axis, which only means anything for a trigger shape. The two policies coexist.
enum class EnvKind { Ahdsr, Ahd };
// Gate nodes: Origin -> AttackEnd -> HoldEnd -> DecayEnd(sustain) -> ReleaseStart -> ReleaseEnd.
// Trigger nodes: Origin -> FadeInEnd -> FadeOutStart -> LengthEnd(playEnd).
// Shared by envelope_overlay (forward/draw map) and envelope_edit (inverse/edit map).
// Gate nodes: Origin -> AttackEnd -> HoldEnd -> DecayEnd(sustain) -> ReleaseStart -> ReleaseEnd.
// AHD nodes: Origin -> AttackEnd -> HoldEnd -> DecayEnd.
// The three *Curve nodes are the round mid-segment knots whose vertical drag sets that
// segment's curve exponent. Shared by envelope_overlay (forward/draw) and envelope_edit
// (inverse/edit).
enum class EnvNode {
Origin, // t=0, level 0 — not draggable
AttackEnd, // Gate: attack ramp top — sets attackSeconds
HoldEnd, // Gate: hold plateau end — sets holdSeconds
DecayEnd, // Gate: decay settles to sustain — sets decaySeconds (X) and sustainLevel (Y)
ReleaseStart, // Gate: sustain plateau end — drawing-only, not draggable
ReleaseEnd, // Gate: release tail end — sets releaseSeconds
FadeInEnd, // Trigger: fade-in top — sets fadeInFraction
FadeOutStart, // Trigger: fade-out start — sets fadeOutFraction
LengthEnd, // Trigger: playEnd terminal — sets lengthFraction
AttackEnd, // attack ramp top — sets attackSeconds
HoldEnd, // hold plateau end — AHDSR: holdSeconds; AHD: holdFraction
DecayEnd, // AHDSR: decay settles to sustain (X = decay, Y = sustain); AHD: decay end
ReleaseStart, // AHDSR: sustain plateau end — sets releaseSeconds (drags on X, inverted)
ReleaseEnd, // AHDSR: the envelope's end point — ANCHORED to the right edge, not draggable
AttackCurve, // mid-attack knot — sets attackCurve
DecayCurve, // mid-decay knot — sets decayCurve
ReleaseCurve, // mid-release knot — sets releaseCurve (AHDSR only)
};
// Amp-envelope params the overlay draws. Trigger's fadeIn/fadeOutFraction are derived from
// TriggerParams' frame counts, not a direct field copy — see the trigger_seam gotcha in
// core/instrument/CLAUDE.md.
struct AmpEnvelope {
EnvMode mode = EnvMode::Gate;
// The envelope the overlay draws. One struct for both policies: `kind` selects which fields
// are read, so a single pack/unpack pair serves the amp, pitch, and filter envelopes.
struct StageEnvelope {
EnvKind kind = EnvKind::Ahdsr;
// Gate (AHDSR): seconds, plus a dimensionless sustain level.
// AHDSR: seconds at the schematic param-domain scale, plus 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 played span.
double lengthFraction = 1.0;
double fadeInFraction = 0.0;
double fadeOutFraction = 0.0;
// AHD: attack/decay seconds plus the Hold FRACTION of the span left after them, laid over
// [originSeconds, originSeconds + spanSeconds) of the waveform's own time axis.
double holdFraction = 1.0;
double originSeconds = 0.0;
double spanSeconds = 0.0;
// Per-segment curve exponents (release is AHDSR-only). curve_law.h owns the domain.
double attackCurve = util::kCurveNeutral;
double decayCurve = util::kCurveNeutral;
double releaseCurve = util::kCurveNeutral;
};
// One polyline vertex: pixel point plus which node it is. level is redundant with y, carried for
// inspection.
// One polyline vertex: pixel point plus which node it is. `level` is redundant with y, carried
// for inspection. `knot` marks the round mid-segment curve handles, which draw differently and
// are not part of the traced line.
struct EnvVertex {
EnvNode node = EnvNode::Origin;
int x = 0;
int y = 0;
double level = 0.0;
bool knot = false;
bool operator==(const EnvVertex& o) const {
return node == o.node && x == o.x && y == o.y && level == o.level;
return node == o.node && x == o.x && y == o.y && level == o.level && knot == o.knot;
}
};
// Fraction of canvas width reserved for the Gate sustain-plateau display; the remaining width
// carries A/H/D/R at the param-domain scale. Shared with envelope_edit.
inline constexpr double kGateSustainDisplayFraction = 0.15;
// Minimum pixel separation between consecutive Gate nodes, so zero-duration stages (tier-0
// Minimum pixel separation between consecutive AHDSR nodes, so zero-duration stages (tier-0
// defaults) still render as distinct, grabbable handles. Larger than envelope_edit's grab
// radius (6) so a click can never tie between neighbours.
inline constexpr int kGateNodeSepPx = 8;
// Gate schematic's per-stage time domain (seconds) — the timed region represents four stages
// end-to-end at this max each. Must match the shell's stage-slider ceiling so a maxed slider
// lands exactly at the canvas edge.
// The AHDSR schematic's per-stage time domain (seconds) — the four timed stages A/H/D/R each
// span at most this. Must match the shell's stage-knob ceiling so a maxed knob lands exactly at
// the canvas edge (at which point the sustain plateau has shrunk to nothing).
inline constexpr double kGateStageMaxSeconds = 2.0;
// Pixel width of the Gate timed region (area width minus the sustain reserve), floored at 1 for
// a non-empty area; 0 for a zero/negative-width area.
int gateTimedWidth(const Rect& area);
// Pixels per second of the Gate timed region, independent of the sample's actual duration.
// Pixels per second of the AHDSR schematic, independent of the sample's actual duration.
// Shared by buildEnvelopePolyline and envelope_edit's drag inverse so a dragged handle tracks
// the cursor 1:1.
double gatePxPerSecond(const Rect& area);
// Maps an amp envelope to polyline vertices inside `area` over a sample of `totalSeconds`
// duration. y maps level [0,1] across [area.bottom()-1, area.y] (level 1 at the top); vertices
// are in draw order, Origin first.
// Maps a staged envelope to polyline vertices inside `area` over a sample of `totalSeconds`
// duration. y maps level [0,1] across [area.bottom()-1, area.y] (level 1 at the top); the
// traced vertices come first in draw order (Origin first), then the curve knots.
//
// Gate's x-axis is a bounded schematic independent of totalSeconds (does NOT line up with the
// waveform under it); Trigger's x-axis is PCM-aligned wall-clock. Every vertex is clamped inside
// the canvas: x in [area.x, area.right()-1], y in [area.y, area.bottom()-1]. A degenerate area
// or totalSeconds <= 0 yields the flat two-point baseline [Origin, end at level 0]. Takes the
// The AHDSR x-axis is a bounded schematic independent of totalSeconds (it does NOT line up with
// the waveform under it) with its ReleaseEnd anchored to the right edge; the AHD x-axis is
// wall-clock, 1:1 with the waveform. Every vertex is clamped inside the canvas: x in
// [area.x, area.right()-1], y in [area.y, area.bottom()-1]. A degenerate area or
// totalSeconds <= 0 yields the flat two-point baseline [Origin, end at level 0]. Takes the
// waveform overlay (not a lane) — see waveform_view.h's overlay contract.
std::vector<EnvVertex> buildEnvelopePolyline(const AmpEnvelope& env, const OverlayArea& area,
std::vector<EnvVertex> buildEnvelopePolyline(const StageEnvelope& env, const OverlayArea& area,
double totalSeconds);
// Maps a time (seconds) to a pixel x inside `area`, linear and clamped at both ends. Shared
@@ -106,4 +111,16 @@ int timeToX(const Rect& area, double totalSeconds, double t);
// clamped. Shared with envelope_edit's node hit-test.
int levelToY(const Rect& area, double level);
// The A/H/D split of an AHD's span, in seconds — the pure-UI mirror of the engine's fitAhd, so
// the drawn stage boundaries land where the voice actually puts them. Attack takes at most the
// span and Decay at most what Attack left, so Hold's fraction of the remainder can never push
// the sum past the span; there is no clamp on the sum because none is possible.
struct AhdSplit {
double attack = 0.0;
double hold = 0.0;
double decay = 0.0;
double total = 0.0;
};
AhdSplit splitAhdSeconds(const StageEnvelope& env);
} // namespace reasampler::instrument::ui
+24 -4
View File
@@ -20,10 +20,11 @@ int knobRowWidth(const DeckGroupDesc& g) {
return w;
}
// The caption-row width: the caption reserve plus the optional caption toggle.
// The caption-row width: the caption reserve plus the optional caption toggle and radio.
int captionRowWidth(const DeckGroupDesc& g) {
int w = g.captionWidth;
if (g.captionToggle.id >= 0) w += kDeckToggleGap + 2 * g.captionToggle.segWidth;
if (g.captionRadio.id >= 0) w += kDeckToggleGap + kDeckRadioSize;
return w;
}
@@ -37,12 +38,22 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) {
const int innerLeft = box.x + kDeckGroupPadX;
const int innerRight = box.right() - kDeckGroupPadX;
// Caption row: text left, compact toggle right-anchored.
// Caption row: text left, then the compact toggle, then the corner radio at the far edge.
out.caption = Rect::ltrb(innerLeft, captionTop, innerRight, captionTop + kDeckCaptionH);
int captionRight = innerRight;
if (g.captionRadio.id >= 0) {
const int radioTop = captionTop + (kDeckCaptionH - kDeckRadioSize) / 2;
out.captionRadio = DeckRadioLayout{
g.captionRadio.id, Rect::ltrb(innerRight - kDeckRadioSize, radioTop, innerRight,
radioTop + kDeckRadioSize)};
captionRight = out.captionRadio.box.x - kDeckToggleGap;
out.caption.width = captionRight - out.caption.x;
}
if (g.captionToggle.id >= 0) {
const int segW = g.captionToggle.segWidth;
const int togTop = captionTop + (kDeckCaptionH - kDeckToggleH) / 2;
const Rect seg1 = Rect::ltrb(innerRight - segW, togTop, innerRight, togTop + kDeckToggleH);
const Rect seg1 = Rect::ltrb(captionRight - segW, togTop, captionRight,
togTop + kDeckToggleH);
const Rect seg0 = Rect::ltrb(seg1.x - segW, togTop, seg1.x, togTop + kDeckToggleH);
out.captionToggle = DeckToggleLayout{g.captionToggle.id, seg0, seg1};
// Caption text stops at the toggle: pull the right edge in (XYWH: shrink width).
@@ -59,6 +70,10 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) {
const int knobLeft = x + (kDeckCellW - kDeckKnobSize) / 2;
const int knobTop = cellTop + 4;
c.knob = Rect::ltrb(knobLeft, knobTop, knobLeft + kDeckKnobSize, knobTop + kDeckKnobSize);
const int innerLeftPx = knobLeft + (kDeckKnobSize - kDeckInnerDialSize) / 2;
const int innerTopPx = knobTop + (kDeckKnobSize - kDeckInnerDialSize) / 2;
c.inner = Rect::ltrb(innerLeftPx, innerTopPx, innerLeftPx + kDeckInnerDialSize,
innerTopPx + kDeckInnerDialSize);
const int labelTop = knobTop + kDeckKnobSize + 4;
c.label = Rect::ltrb(c.cell.x, labelTop, c.cell.right(), labelTop + kDeckCellLabelH);
out.cells.push_back(c);
@@ -133,6 +148,9 @@ DeckLayout layoutDeck(const std::vector<DeckGroupDesc>& groups, int left, int to
DeckHit hitTestDeck(const DeckLayout& layout, int x, int y) {
for (const DeckGroupLayout& g : layout.groups) {
if (!contains(g.box, x, y)) continue;
if (g.captionRadio.id >= 0 && contains(g.captionRadio.box, x, y)) {
return {DeckHitKind::CaptionRadio, g.captionRadio.id, -1, false};
}
if (g.captionToggle.id >= 0) {
if (contains(g.captionToggle.seg0, x, y))
return {DeckHitKind::CaptionToggle, g.captionToggle.id, 0};
@@ -146,7 +164,9 @@ DeckHit hitTestDeck(const DeckLayout& layout, int x, int y) {
return {DeckHitKind::RowToggle, g.rowToggle.id, 1};
}
for (const DeckCellLayout& c : g.cells) {
if (c.id >= 0 && contains(c.cell, x, y)) return {DeckHitKind::Knob, c.id, -1};
if (c.id >= 0 && contains(c.cell, x, y)) {
return {DeckHitKind::Knob, c.id, -1, contains(c.inner, x, y)};
}
}
return {}; // inside the box but on fence/padding/blank — a miss (groups never overlap)
}
+26 -5
View File
@@ -35,6 +35,11 @@ inline constexpr int kDeckCaptionGap = 2; // caption row -> knob row gap
inline constexpr int kDeckToggleGap = 4; // caption text -> toggle / cells -> row toggle gap
inline constexpr int kDeckGroupGap = 12; // gap between groups on a row
inline constexpr int kDeckRowGap = 8; // gap between wrapped deck rows
inline constexpr int kDeckRadioSize = 12; // the caption-row corner radio square
// The knob cell's INNER dial: a concentric sub-disc that edits a second, related value while
// the outer ring keeps editing the cell's own. Geometry only — WHICH cells carry one is
// deck_groups' call, so a cell without an inner value simply resolves an inner hit as a knob.
inline constexpr int kDeckInnerDialSize = 14;
// One group box: padding + caption + gap + cell row + padding.
inline constexpr int kDeckGroupH =
kDeckGroupPadY + kDeckCaptionH + kDeckCaptionGap + kDeckCellH + kDeckGroupPadY;
@@ -45,13 +50,20 @@ struct DeckToggleDesc {
int segWidth = 44; // px per segment
};
// A single-square corner radio (an exclusive selector across groups, so the group itself
// carries no state). id -1 = absent.
struct DeckRadioDesc {
int id = -1;
};
// One fenced group, in deck order. `cellIds` are the knob cells left-to-right; an id of -1
// is a reserved blank cell (geometry held, never hit). `captionWidth` is the px the shell
// reserves for the caption text (this module does not measure text).
struct DeckGroupDesc {
int id = 0; // shell group id (opaque here)
int captionWidth = 60;
DeckToggleDesc captionToggle; // right-anchored in the caption row; id -1 = none
DeckRadioDesc captionRadio; // the caption row's far corner; id -1 = none
DeckToggleDesc captionToggle; // caption row, left of the radio; id -1 = none
std::vector<int> cellIds; // knob cells; -1 = blank reserve
DeckToggleDesc rowToggle; // in the knob row after the cells; id -1 = none
};
@@ -64,10 +76,16 @@ struct DeckToggleLayout {
Rect seg1; // right segment
};
struct DeckRadioLayout {
int id = -1;
Rect box;
};
struct DeckCellLayout {
int id = -1;
Rect cell; // the full 48x58 cell
Rect knob; // the centered kDeckKnobSize square (the knob circle inscribes it)
Rect inner; // the concentric kDeckInnerDialSize square inside `knob`
Rect label; // the 12px label band beneath the knob
};
@@ -75,6 +93,7 @@ struct DeckGroupLayout {
int id = 0;
Rect box; // the fenced group box
Rect caption; // caption text rect (left part of the caption row)
DeckRadioLayout captionRadio; // id -1 when absent (rect empty)
DeckToggleLayout captionToggle; // id -1 when absent (rects empty)
std::vector<DeckCellLayout> cells;
DeckToggleLayout rowToggle; // id -1 when absent
@@ -105,17 +124,19 @@ DeckLayout layoutDeck(const std::vector<DeckGroupDesc>& groups, int left, int to
// --- Hit-test --------------------------------------------------------------------------
enum class DeckHitKind { None, Knob, CaptionToggle, RowToggle };
enum class DeckHitKind { None, Knob, CaptionToggle, RowToggle, CaptionRadio };
struct DeckHit {
DeckHitKind kind = DeckHitKind::None;
int id = -1; // the control id of the hit element (cell id / toggle id)
int id = -1; // the control id of the hit element (cell id / toggle id / radio id)
int segment = -1; // 0/1 for a toggle hit; -1 otherwise
bool inner = false; // Knob hits only: the grab landed on the cell's inner dial
};
// The deck element a point lands on: a knob cell (the whole cell, not just the knob
// circle — the shell anchors the vertical drag wherever the grab lands), a caption-toggle
// segment, or a row-toggle segment. Blank cells (id -1) and everything else miss.
// circle — the shell anchors the vertical drag wherever the grab lands, with `inner` marking
// a grab on the concentric inner dial), a caption-toggle segment, a row-toggle segment, or
// the caption-row corner radio. Blank cells (id -1) and everything else miss.
DeckHit hitTestDeck(const DeckLayout& layout, int x, int y);
} // namespace reasampler::instrument::ui