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
+221 -99
View File
@@ -1,12 +1,14 @@
// editor_controls.cpp — the ReaSamplerEditor's parameter plumbing: the band-stack layout
// resolve every paint/hit-test path shares, the control-value domain maps (controlValue /
// applyControl — seconds/fraction/frames <-> normalized 0..1), the control-id<->value binding
// against the pure `deck_groups` module's descriptors, and the envelope pack/unpack (the
// trigger-seam converter). Value logic only — no painting, no window plumbing.
// against the pure `deck_groups` module's descriptors, and the envelope pack/unpack (which
// stored struct each overlay selection maps onto). Value logic only — no painting, no window
// plumbing.
#include "shell/instrument/reasampler_editor.h"
#include <algorithm>
#include <cmath> // log/exp (the curve knob's logarithmic travel)
#include <cstdint>
#include <cstdio> // snprintf (deck value labels)
#include <string>
@@ -14,17 +16,17 @@
#include "core/instrument/engine/filter/filter_params.h" // the filter's own control laws
#include "core/instrument/engine/master_gain.h" // master-gain dB<->linear<->knob taper
#include "core/instrument/map/trigger_seam.h" // triggerPlayLength / fade fraction converters
#include "core/instrument/map/trigger_seam.h" // triggerPlayLength (the Trigger play span)
#include "core/instrument/ui/deck_groups.h" // sampleDeckGroups (the deck's composition)
#include "core/instrument/ui/knob_deck.h" // deckHeight / kDeckKnobSize (the band's own height)
#include "core/util/clamp01.h"
#include "core/util/curve_law.h" // the ONE curve-exponent domain
#include "shell/instrument/editor_internal.h"
#include "shell/instrument/reasampler_processor.h"
namespace reasampler::vst {
using namespace reasampler::instrument::map; // PlaySeconds vocabulary + trigger_seam converters
using instrument::ui::EnvMode; // envelope_overlay's mode enum
using namespace reasampler::instrument::map; // PlaySeconds vocabulary + trigger_seam
using instrument::ui::computeSampleBands;
using instrument::ui::chromeRects;
using instrument::ui::deckHeight;
@@ -44,17 +46,26 @@ using util::clamp01;
namespace {
// Control-surface value domains (the shell owns these — param_slider is engine-free and maps
// only 0..1). Wall-clock time sliders (AHDSR A/H/D/R, pitch env A/D) span [0, kEnvTimeMaxSeconds]
// seconds — rate-free, exactly what the parameter set stores; the build resolves seconds->frames
// at the live rate. Source-timeline fade sliders (Trigger fade-in/out) store source frames
// (never a wall-clock second), but the knob's full-scale throw is a wall-clock intent —
// kFadeMaxSeconds resolved against the live rate at use (fadeMaxFrames()) rather than a baked-in
// rate constant, per the no-hardcoded-rate ruling.
constexpr double kEnvTimeMaxSeconds = 2.0; // AHDSR A/H/D/R + pitch A/D throw ceiling (seconds)
constexpr double kFadeMaxSeconds = 2.0; // Trigger fade throw ceiling (wall-clock)
constexpr double kPitchDepthMaxSemis = 24.0; // AD pitch depth throw: +/-24 st, centered
// only 0..1). Every stage-time knob spans [0, kEnvTimeMaxSeconds] seconds — rate-free, exactly
// what the parameter set stores; the build resolves seconds->frames at the live rate. No knob
// on this surface stores a source-frame count any more, so none needs a rate to draw.
constexpr double kEnvTimeMaxSeconds = 2.0; // every stage-time knob's ceiling (seconds)
constexpr double kPitchDepthMaxSemis = 24.0; // pitch depth throw: +/-24 st, centered
constexpr double kKeyTrackMax = 2.0; // key-track slider ceiling (0..200%)
// A curve exponent's knob travel is LOGARITHMIC: 0.5 is the linear neutral, so the two halves
// of the throw are the reciprocal shaping directions and the neutral sits at a centre detent.
double curveFromNorm(double norm) {
const double t = clamp01(norm);
return std::exp(std::log(util::kCurveMin) +
t * (std::log(util::kCurveMax) - std::log(util::kCurveMin)));
}
double normFromCurve(double curve) {
const double c = util::clampCurve(curve);
return clamp01((std::log(c) - std::log(util::kCurveMin)) /
(std::log(util::kCurveMax) - std::log(util::kCurveMin)));
}
} // namespace
ReaSamplerEditor::FaceLayout ReaSamplerEditor::faceLayout(int w, int h) const {
@@ -69,16 +80,9 @@ ReaSamplerEditor::FaceLayout ReaSamplerEditor::faceLayout(int w, int h) const {
}
double ReaSamplerEditor::controlValue(int id, const PlaySeconds& play) const {
// Wall-clock seconds -> normalized over the seconds ceiling; source frames -> normalized over
// the rate-resolved frames ceiling. Two domains, kept explicit so neither leaks a rate. A
// stored fade exceeding fadeMaxFrames() at the current host rate reads as norm 1.0 (clamp01
// pins it) and gets rewritten down on the next knob touch.
const double fadeMax = fadeMaxFrames();
// Wall-clock seconds -> normalized over the seconds ceiling; fractions and normalized
// control positions pass through; curve exponents take the log travel.
const auto secToNorm = [](double s) { return clamp01(s / kEnvTimeMaxSeconds); };
const auto framesToNorm = [fadeMax](std::int64_t f) {
// Ceiling unavailable (rate not yet known): inert until fadeMaxFrames() resolves.
return fadeMax > 0.0 ? clamp01(static_cast<double>(f) / fadeMax) : 0.0;
};
switch (static_cast<ParamControl>(id)) {
case ParamControl::kPlayMode: return play.playMode == PlayMode::Trigger ? 1.0 : 0.0;
case ParamControl::kPitchEngine: return play.pitchEngine == PitchEngine::Preserve ? 1.0 : 0.0;
@@ -87,12 +91,23 @@ double ReaSamplerEditor::controlValue(int id, const PlaySeconds& play) const {
case ParamControl::kDecay: return secToNorm(play.adsr.decaySeconds);
case ParamControl::kSustain: return clamp01(play.adsr.sustainLevel);
case ParamControl::kRelease: return secToNorm(play.adsr.releaseSeconds);
case ParamControl::kAttackCurve: return normFromCurve(play.adsr.attackCurve);
case ParamControl::kDecayCurve: return normFromCurve(play.adsr.decayCurve);
case ParamControl::kReleaseCurve: return normFromCurve(play.adsr.releaseCurve);
case ParamControl::kTrigLength: return clamp01(play.trigger.lengthFraction);
case ParamControl::kTrigFadeIn: return framesToNorm(play.trigger.fadeInFrames);
case ParamControl::kTrigFadeOut: return framesToNorm(play.trigger.fadeOutFrames);
case ParamControl::kTrigAttack: return secToNorm(play.trigAhd.attackSeconds);
case ParamControl::kTrigHold: return clamp01(play.trigAhd.holdFraction);
case ParamControl::kTrigDecay: return secToNorm(play.trigAhd.decaySeconds);
case ParamControl::kTrigAttackCurve: return normFromCurve(play.trigAhd.attackCurve);
case ParamControl::kTrigDecayCurve: return normFromCurve(play.trigAhd.decayCurve);
case ParamControl::kPitchEnvEnable:return play.pitchEnv.enabled ? 1.0 : 0.0;
case ParamControl::kPitchEnvAttack:return secToNorm(play.pitchEnv.attackSeconds);
case ParamControl::kPitchEnvDecay: return secToNorm(play.pitchEnv.decaySeconds);
case ParamControl::kPitchEnvAttack:return secToNorm(play.pitchEnv.shape.attackSeconds);
case ParamControl::kPitchEnvHold: return clamp01(play.pitchEnv.shape.holdFraction);
case ParamControl::kPitchEnvDecay: return secToNorm(play.pitchEnv.shape.decaySeconds);
case ParamControl::kPitchEnvAttackCurve:
return normFromCurve(play.pitchEnv.shape.attackCurve);
case ParamControl::kPitchEnvDecayCurve:
return normFromCurve(play.pitchEnv.shape.decayCurve);
case ParamControl::kPitchEnvDepth:
// Signed depth centered at 0.5 (0.5 == 0 semitones).
return clamp01(0.5 + play.pitchEnv.peakSemitones / (2.0 * kPitchDepthMaxSemis));
@@ -113,19 +128,26 @@ double ReaSamplerEditor::controlValue(int id, const PlaySeconds& play) const {
case ParamControl::kFilterEnvDecay: return secToNorm(play.filter.env.decaySeconds);
case ParamControl::kFilterEnvSustain: return clamp01(play.filter.env.sustainLevel);
case ParamControl::kFilterEnvRelease: return secToNorm(play.filter.env.releaseSeconds);
case ParamControl::kFilterEnvAttackCurve:
return normFromCurve(play.filter.env.attackCurve);
case ParamControl::kFilterEnvDecayCurve:
return normFromCurve(play.filter.env.decayCurve);
case ParamControl::kFilterEnvReleaseCurve:
return normFromCurve(play.filter.env.releaseCurve);
case ParamControl::kFilterTrigAttack: return secToNorm(play.filter.trigEnv.attackSeconds);
case ParamControl::kFilterTrigHold: return clamp01(play.filter.trigEnv.holdFraction);
case ParamControl::kFilterTrigDecay: return secToNorm(play.filter.trigEnv.decaySeconds);
case ParamControl::kFilterTrigAttackCurve:
return normFromCurve(play.filter.trigEnv.attackCurve);
case ParamControl::kFilterTrigDecayCurve:
return normFromCurve(play.filter.trigEnv.decayCurve);
default: return 0.0;
}
}
void ReaSamplerEditor::applyControl(int id, PlaySeconds& play, double value,
int segment) const {
const double fadeMax = fadeMaxFrames(); // rate-resolved knob full-scale
const auto normToSec = [](double v) { return clamp01(v) * kEnvTimeMaxSeconds; };
const auto normToFrames = [fadeMax](double v) -> std::int64_t {
// Ceiling unavailable (rate not yet known): inert until fadeMaxFrames() resolves.
if (fadeMax <= 0.0) return 0;
return static_cast<std::int64_t>(clamp01(v) * fadeMax + 0.5);
};
switch (static_cast<ParamControl>(id)) {
case ParamControl::kPlayMode:
play.playMode = (segment == 1) ? PlayMode::Trigger : PlayMode::Gate;
@@ -138,17 +160,33 @@ void ReaSamplerEditor::applyControl(int id, PlaySeconds& play, double value,
case ParamControl::kDecay: play.adsr.decaySeconds = normToSec(value); break;
case ParamControl::kSustain: play.adsr.sustainLevel = clamp01(value); break;
case ParamControl::kRelease: play.adsr.releaseSeconds = normToSec(value); break;
case ParamControl::kAttackCurve: play.adsr.attackCurve = curveFromNorm(value); break;
case ParamControl::kDecayCurve: play.adsr.decayCurve = curveFromNorm(value); break;
case ParamControl::kReleaseCurve: play.adsr.releaseCurve = curveFromNorm(value); break;
case ParamControl::kTrigLength:
// lengthFraction is (0,1]; keep a small floor so a zero-length trigger never plays nothing.
play.trigger.lengthFraction = (std::max)(0.01, clamp01(value));
break;
case ParamControl::kTrigFadeIn: play.trigger.fadeInFrames = normToFrames(value); break;
case ParamControl::kTrigFadeOut: play.trigger.fadeOutFrames = normToFrames(value); break;
case ParamControl::kTrigAttack: play.trigAhd.attackSeconds = normToSec(value); break;
case ParamControl::kTrigHold: play.trigAhd.holdFraction = clamp01(value); break;
case ParamControl::kTrigDecay: play.trigAhd.decaySeconds = normToSec(value); break;
case ParamControl::kTrigAttackCurve:
play.trigAhd.attackCurve = curveFromNorm(value); break;
case ParamControl::kTrigDecayCurve:
play.trigAhd.decayCurve = curveFromNorm(value); break;
case ParamControl::kPitchEnvEnable:
play.pitchEnv.enabled = (segment == 1);
break;
case ParamControl::kPitchEnvAttack: play.pitchEnv.attackSeconds = normToSec(value); break;
case ParamControl::kPitchEnvDecay: play.pitchEnv.decaySeconds = normToSec(value); break;
case ParamControl::kPitchEnvAttack:
play.pitchEnv.shape.attackSeconds = normToSec(value); break;
case ParamControl::kPitchEnvHold:
play.pitchEnv.shape.holdFraction = clamp01(value); break;
case ParamControl::kPitchEnvDecay:
play.pitchEnv.shape.decaySeconds = normToSec(value); break;
case ParamControl::kPitchEnvAttackCurve:
play.pitchEnv.shape.attackCurve = curveFromNorm(value); break;
case ParamControl::kPitchEnvDecayCurve:
play.pitchEnv.shape.decayCurve = curveFromNorm(value); break;
case ParamControl::kPitchEnvDepth:
play.pitchEnv.peakSemitones = (clamp01(value) - 0.5) * 2.0 * kPitchDepthMaxSemis;
break;
@@ -179,6 +217,22 @@ void ReaSamplerEditor::applyControl(int id, PlaySeconds& play, double value,
play.filter.env.sustainLevel = clamp01(value); break;
case ParamControl::kFilterEnvRelease:
play.filter.env.releaseSeconds = normToSec(value); break;
case ParamControl::kFilterEnvAttackCurve:
play.filter.env.attackCurve = curveFromNorm(value); break;
case ParamControl::kFilterEnvDecayCurve:
play.filter.env.decayCurve = curveFromNorm(value); break;
case ParamControl::kFilterEnvReleaseCurve:
play.filter.env.releaseCurve = curveFromNorm(value); break;
case ParamControl::kFilterTrigAttack:
play.filter.trigEnv.attackSeconds = normToSec(value); break;
case ParamControl::kFilterTrigHold:
play.filter.trigEnv.holdFraction = clamp01(value); break;
case ParamControl::kFilterTrigDecay:
play.filter.trigEnv.decaySeconds = normToSec(value); break;
case ParamControl::kFilterTrigAttackCurve:
play.filter.trigEnv.attackCurve = curveFromNorm(value); break;
case ParamControl::kFilterTrigDecayCurve:
play.filter.trigEnv.decayCurve = curveFromNorm(value); break;
default: break;
}
}
@@ -187,18 +241,6 @@ double ReaSamplerEditor::liveSampleRate() const {
return processor_ ? processor_->sampleRate() : 0.0;
}
double ReaSamplerEditor::fadeMaxFrames() const {
// The Trigger-fade knob's full-scale throw is kFadeMaxSeconds (2 s wall-clock) resolved
// against the live rate — the same time base the envelope overlay already uses to place
// these source-frame fades on screen. Pre-setupProcessing the rate is still 0: rather than
// substitute a literal rate, callers treat a <= 0 return as "ceiling unavailable yet" and
// degrade the knob to inert rather than guess a rate. Storage stays source frames — this
// resolves the UI ceiling only.
const double rate = liveSampleRate();
if (rate <= 0.0) return 0.0;
return kFadeMaxSeconds * rate;
}
double ReaSamplerEditor::previewVelocity01() const {
if (!processor_) return static_cast<double>(kPreviewVelocityDefault) / 127.0;
return static_cast<double>(processor_->previewVelocity()) / 127.0;
@@ -267,16 +309,18 @@ std::string ReaSamplerEditor::deckValueLabel(int id) const {
snprintf(buf, sizeof(buf), "%.3fs", play.adsr.releaseSeconds); break;
case ParamControl::kTrigLength:
snprintf(buf, sizeof(buf), "%.0f%%", play.trigger.lengthFraction * 100.0); break;
case ParamControl::kTrigFadeIn:
snprintf(buf, sizeof(buf), "%lldf",
static_cast<long long>(play.trigger.fadeInFrames)); break;
case ParamControl::kTrigFadeOut:
snprintf(buf, sizeof(buf), "%lldf",
static_cast<long long>(play.trigger.fadeOutFrames)); break;
case ParamControl::kTrigAttack:
snprintf(buf, sizeof(buf), "%.3fs", play.trigAhd.attackSeconds); break;
case ParamControl::kTrigHold:
snprintf(buf, sizeof(buf), "%.0f%%", play.trigAhd.holdFraction * 100.0); break;
case ParamControl::kTrigDecay:
snprintf(buf, sizeof(buf), "%.3fs", play.trigAhd.decaySeconds); break;
case ParamControl::kPitchEnvAttack:
snprintf(buf, sizeof(buf), "%.3fs", play.pitchEnv.attackSeconds); break;
snprintf(buf, sizeof(buf), "%.3fs", play.pitchEnv.shape.attackSeconds); break;
case ParamControl::kPitchEnvHold:
snprintf(buf, sizeof(buf), "%.0f%%", play.pitchEnv.shape.holdFraction * 100.0); break;
case ParamControl::kPitchEnvDecay:
snprintf(buf, sizeof(buf), "%.3fs", play.pitchEnv.decaySeconds); break;
snprintf(buf, sizeof(buf), "%.3fs", play.pitchEnv.shape.decaySeconds); break;
case ParamControl::kPitchEnvDepth:
snprintf(buf, sizeof(buf), "%+.1fst", play.pitchEnv.peakSemitones); break;
case ParamControl::kKeyTrack:
@@ -322,6 +366,27 @@ std::string ReaSamplerEditor::deckValueLabel(int id) const {
snprintf(buf, sizeof(buf), "%.0f%%", play.filter.env.sustainLevel * 100.0); break;
case ParamControl::kFilterEnvRelease:
snprintf(buf, sizeof(buf), "%.3fs", play.filter.env.releaseSeconds); break;
case ParamControl::kFilterTrigAttack:
snprintf(buf, sizeof(buf), "%.3fs", play.filter.trigEnv.attackSeconds); break;
case ParamControl::kFilterTrigHold:
snprintf(buf, sizeof(buf), "%.0f%%", play.filter.trigEnv.holdFraction * 100.0); break;
case ParamControl::kFilterTrigDecay:
snprintf(buf, sizeof(buf), "%.3fs", play.filter.trigEnv.decaySeconds); break;
// Every curve exponent reads the same way: the neutral shows as 1.00.
case ParamControl::kAttackCurve:
case ParamControl::kDecayCurve:
case ParamControl::kReleaseCurve:
case ParamControl::kTrigAttackCurve:
case ParamControl::kTrigDecayCurve:
case ParamControl::kPitchEnvAttackCurve:
case ParamControl::kPitchEnvDecayCurve:
case ParamControl::kFilterEnvAttackCurve:
case ParamControl::kFilterEnvDecayCurve:
case ParamControl::kFilterEnvReleaseCurve:
case ParamControl::kFilterTrigAttackCurve:
case ParamControl::kFilterTrigDecayCurve:
snprintf(buf, sizeof(buf), "^%.2f", curveFromNorm(controlValue(id, play)));
break;
default:
// -2 (preview velocity) is labeled at its chrome call site; nothing else here.
break;
@@ -330,61 +395,118 @@ std::string ReaSamplerEditor::deckValueLabel(int id) const {
}
EnvClampBounds ReaSamplerEditor::envClampBounds() const {
// Match the control-panel sliders' own domains so a node drag can never produce a param a
// slider couldn't. AHDSR seconds cap at kEnvTimeMaxSeconds; the Trigger fade/length
// fractions cap at 1.0 (the natural full-span bound the sliders use).
// Match the deck knobs' own domains so a node drag can never produce a param a knob
// couldn't. Every stage time caps at kEnvTimeMaxSeconds; the Hold fractions and the sustain
// level are [0,1] by definition and need no bound here.
EnvClampBounds b;
b.maxAttackSeconds = kEnvTimeMaxSeconds;
b.maxHoldSeconds = kEnvTimeMaxSeconds;
b.maxDecaySeconds = kEnvTimeMaxSeconds;
b.maxReleaseSeconds = kEnvTimeMaxSeconds;
b.maxFadeInFraction = 1.0;
b.maxFadeOutFraction = 1.0;
b.maxLengthFraction = 1.0;
return b;
}
AmpEnvelope ReaSamplerEditor::packEnvelope(const PlaySeconds& play, std::int64_t frames,
std::int64_t startFrame) const {
AmpEnvelope env;
env.mode = (play.playMode == PlayMode::Trigger) ? EnvMode::Trigger : EnvMode::Gate;
// AHDSR seconds copy 1-to-1 (rate-free, the same domain the overlay draws).
env.attackSeconds = play.adsr.attackSeconds;
env.holdSeconds = play.adsr.holdSeconds;
env.decaySeconds = play.adsr.decaySeconds;
env.sustainLevel = play.adsr.sustainLevel;
env.releaseSeconds = play.adsr.releaseSeconds;
// Trigger: lengthFraction copies 1-to-1; the fades are derived — source frames over the played
// span (the trigger-seam converter, pack direction). startFrame is the effective start
// point so the fraction denominator matches the voice's actual post-start span. A zero play
// length yields 0 fractions.
env.lengthFraction = play.trigger.lengthFraction;
ReaSamplerEditor::OverlayEnv ReaSamplerEditor::overlayEnvForRadio(int radioId) {
switch (static_cast<ParamControl>(radioId)) {
case ParamControl::kAmpEnvSelect: return OverlayEnv::kAmp;
case ParamControl::kPitchEnvSelect: return OverlayEnv::kPitch;
case ParamControl::kFilterEnvSelect: return OverlayEnv::kFilter;
default: return OverlayEnv::kNone;
}
}
namespace {
// The two directions of the AHDSR <-> StageEnvelope copy, so a field can only be forgotten in
// one place rather than two.
void packAhdsr(const AdsrSeconds& a, StageEnvelope& env) {
env.kind = instrument::ui::EnvKind::Ahdsr;
env.attackSeconds = a.attackSeconds;
env.holdSeconds = a.holdSeconds;
env.decaySeconds = a.decaySeconds;
env.sustainLevel = a.sustainLevel;
env.releaseSeconds = a.releaseSeconds;
env.attackCurve = a.attackCurve;
env.decayCurve = a.decayCurve;
env.releaseCurve = a.releaseCurve;
}
void unpackAhdsr(const StageEnvelope& env, AdsrSeconds& a) {
a.attackSeconds = env.attackSeconds;
a.holdSeconds = env.holdSeconds;
a.decaySeconds = env.decaySeconds;
a.sustainLevel = env.sustainLevel;
a.releaseSeconds = env.releaseSeconds;
a.attackCurve = env.attackCurve;
a.decayCurve = env.decayCurve;
a.releaseCurve = env.releaseCurve;
}
void packAhd(const AhdSeconds& a, double originSeconds, double spanSeconds, StageEnvelope& env) {
env.kind = instrument::ui::EnvKind::Ahd;
env.attackSeconds = a.attackSeconds;
env.decaySeconds = a.decaySeconds;
env.holdFraction = a.holdFraction;
env.attackCurve = a.attackCurve;
env.decayCurve = a.decayCurve;
env.originSeconds = originSeconds;
env.spanSeconds = spanSeconds;
}
void unpackAhd(const StageEnvelope& env, AhdSeconds& a) {
a.attackSeconds = env.attackSeconds;
a.decaySeconds = env.decaySeconds;
a.holdFraction = env.holdFraction;
a.attackCurve = env.attackCurve;
a.decayCurve = env.decayCurve;
}
} // namespace
StageEnvelope ReaSamplerEditor::packEnvelope(OverlayEnv which, const PlaySeconds& play,
std::int64_t frames,
std::int64_t startFrame) const {
StageEnvelope env;
const double rate = liveSampleRate();
const double t0 = rate > 0.0 ? static_cast<double>(startFrame) / rate : 0.0;
// The Trigger amp and filter AHDs live over the PLAY span; the pitch AHD over the whole
// post-start span, since it keeps running after a Trigger one-shot's amplitude has ended.
const std::int64_t playLen =
triggerPlayLength(play.trigger.lengthFraction, frames, startFrame);
env.fadeInFraction = framesToFadeFraction(play.trigger.fadeInFrames, playLen);
env.fadeOutFraction = framesToFadeFraction(play.trigger.fadeOutFrames, playLen);
const double playSpan = rate > 0.0 ? static_cast<double>(playLen) / rate : 0.0;
const double fullSpan =
rate > 0.0 ? static_cast<double>((std::max)(std::int64_t{0}, frames - startFrame)) / rate
: 0.0;
const bool trigger = (play.playMode == PlayMode::Trigger);
switch (which) {
case OverlayEnv::kPitch:
packAhd(play.pitchEnv.shape, t0, fullSpan, env);
break;
case OverlayEnv::kFilter:
if (trigger) packAhd(play.filter.trigEnv, t0, playSpan, env);
else packAhdsr(play.filter.env, env);
break;
case OverlayEnv::kAmp:
case OverlayEnv::kNone:
if (trigger) packAhd(play.trigAhd, t0, playSpan, env);
else packAhdsr(play.adsr, env);
break;
}
return env;
}
void ReaSamplerEditor::unpackEnvelope(const AmpEnvelope& env, std::int64_t frames,
std::int64_t startFrame, PlaySeconds& play) const {
if (env.mode == EnvMode::Gate) {
play.adsr.attackSeconds = env.attackSeconds;
play.adsr.holdSeconds = env.holdSeconds;
play.adsr.decaySeconds = env.decaySeconds;
play.adsr.sustainLevel = env.sustainLevel;
play.adsr.releaseSeconds = env.releaseSeconds;
} else {
// Trigger: lengthFraction copies back; the fades convert fractions -> source frames over
// the played span (the trigger-seam converter, unpack direction). startFrame is the
// effective start point so the frame denominator matches the voice's actual
// post-start span. Keep the same (0,1] floor on lengthFraction the slider path enforces
// so a zero-length trigger never plays nothing.
play.trigger.lengthFraction = (std::max)(0.01, env.lengthFraction);
const std::int64_t playLen =
triggerPlayLength(play.trigger.lengthFraction, frames, startFrame);
play.trigger.fadeInFrames = fadeFractionToFrames(env.fadeInFraction, playLen);
play.trigger.fadeOutFrames = fadeFractionToFrames(env.fadeOutFraction, playLen);
void ReaSamplerEditor::unpackEnvelope(OverlayEnv which, const StageEnvelope& env,
PlaySeconds& play) const {
const bool trigger = (play.playMode == PlayMode::Trigger);
switch (which) {
case OverlayEnv::kPitch:
unpackAhd(env, play.pitchEnv.shape);
break;
case OverlayEnv::kFilter:
if (trigger) unpackAhd(env, play.filter.trigEnv);
else unpackAhdsr(env, play.filter.env);
break;
case OverlayEnv::kAmp:
if (trigger) unpackAhd(env, play.trigAhd);
else unpackAhdsr(env, play.adsr);
break;
case OverlayEnv::kNone:
break; // nothing is overlay-active, so there is nothing a drag could have edited
}
}
+1
View File
@@ -78,6 +78,7 @@ void ReaSamplerEditor::onMouseUp(int x, int y) {
const Rect curveRect = dragCurveRect_;
drag_ = DragKind::kNone;
dragParamId_ = -1;
dragInnerCellId_ = -1;
curvePointIndex_ = -1;
// hover_ is deliberately not re-resolved during a drag (see resolveHover's caller), so it
// still names wherever the drag started. Re-resolve now against the release position, for
+25 -2
View File
@@ -19,6 +19,7 @@ using namespace reasampler::instrument::ui;
bool ReaSamplerEditor::deckKnobDisabled(int id) const {
switch (static_cast<ParamControl>(id)) {
case ParamControl::kPitchEnvAttack:
case ParamControl::kPitchEnvHold:
case ParamControl::kPitchEnvDecay:
case ParamControl::kPitchEnvDepth:
return !params_.play.pitchEnv.enabled;
@@ -34,6 +35,9 @@ bool ReaSamplerEditor::deckKnobDisabled(int id) const {
case ParamControl::kFilterEnvDecay:
case ParamControl::kFilterEnvSustain:
case ParamControl::kFilterEnvRelease:
case ParamControl::kFilterTrigAttack:
case ParamControl::kFilterTrigHold:
case ParamControl::kFilterTrigDecay:
return !params_.play.filter.enabled;
default:
return false;
@@ -46,6 +50,14 @@ bool ReaSamplerEditor::mouseDownDeck(const FaceLayout& fl, int x, int y) {
const DeckLayout dl = layoutDeck(fl.deckDescs, band.x, band.y, band.width);
const DeckHit hit = hitTestDeck(dl, x, y);
if (hit.kind == DeckHitKind::CaptionRadio) {
// Exclusive across the three envelope decks, and clicking the active one clears it —
// "no envelope shown" is a state the user can get back to, not an error.
const OverlayEnv picked = overlayEnvForRadio(hit.id);
overlayEnv_ = (overlayEnv_ == picked) ? OverlayEnv::kNone : picked;
invalidate(); // view state only: no parameter write, no reload
return true;
}
if (hit.kind == DeckHitKind::CaptionToggle || hit.kind == DeckHitKind::RowToggle) {
switch (static_cast<ParamControl>(hit.id)) {
case ParamControl::kVoiceMode: {
@@ -87,9 +99,14 @@ bool ReaSamplerEditor::mouseDownDeck(const FaceLayout& fl, int x, int y) {
if (hit.kind == DeckHitKind::Knob) {
// Knobs of a disabled group are drawn but inert.
if (deckKnobDisabled(hit.id)) return true;
// A grab on the inner disc drags the CURVE control instead, but only where the stage
// is sloped; on a Hold or Sustain cell the inner region is just more of the knob.
const ParamControl curve = curveParamFor(static_cast<ParamControl>(hit.id));
const bool inner = hit.inner && curve != ParamControl::kCount;
drag_ = DragKind::kDeckKnob;
dragParamId_ = hit.id;
dragKnobStartValue_ = deckControlNorm(hit.id);
dragParamId_ = inner ? static_cast<int>(curve) : hit.id;
dragInnerCellId_ = inner ? hit.id : -1;
dragKnobStartValue_ = deckControlNorm(dragParamId_);
// Processor-side knobs (voice count / master gain) are transient live writes with no
// parameter-set mutation, so they need no rollback snapshot.
dragStartParams_ = params_;
@@ -120,6 +137,12 @@ ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverDeck(const FaceLayout& fl,
const DeckLayout dl = layoutDeck(fl.deckDescs, band.x, band.y, band.width);
const DeckHit dh = hitTestDeck(dl, x, y);
if (dh.kind == DeckHitKind::None) return {};
if (dh.kind == DeckHitKind::CaptionRadio) return {HoverKind::kEnvRadio, dh.id};
if (dh.kind == DeckHitKind::Knob && dh.inner &&
curveParamFor(static_cast<ParamControl>(dh.id)) != ParamControl::kCount) {
// Indexed by the OUTER cell id so the paint side can find the cell it belongs to.
return {HoverKind::kInnerDial, dh.id};
}
return {HoverKind::kControl, dh.id};
}
+11 -12
View File
@@ -28,11 +28,12 @@ bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) {
if (frames <= 0) return false;
const OverlayArea overlay = waveformOverlayArea(fl.bands.waveform);
// Envelope nodes first (they sit on top of the markers), then the wave markers.
// Envelope nodes first (they sit on top of the markers), then the wave markers. With no
// envelope overlay-active there are no nodes at all and the markers take every grab.
const double rate = liveSampleRate();
if (rate > 0.0) {
if (rate > 0.0 && overlayEnv_ != OverlayEnv::kNone) {
const std::int64_t startFrame = params_.startPoint.value_or(0);
const AmpEnvelope env = packEnvelope(params_.play, frames, startFrame);
const StageEnvelope env = packEnvelope(overlayEnv_, params_.play, frames, startFrame);
const double totalSeconds = static_cast<double>(frames) / rate;
const NodeHit nh = nodeAtPoint(env, overlay, totalSeconds, x, y);
if (nh.hit) {
@@ -42,7 +43,6 @@ bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) {
dragStartY_ = y;
dragStartEnv_ = env;
dragSampleFrames_ = frames;
dragStartFrame_ = startFrame;
dragStartParams_ = params_;
return true; // node moves once the cursor drags
}
@@ -68,18 +68,17 @@ void ReaSamplerEditor::dragWaveform(const FaceLayout& fl, int x, int y) {
if (drag_ == DragKind::kEnvNode) {
// Resolve the grabbed envelope node's new params from the pixel delta (through the
// pure envelope_edit inverse map, clamped + monotonic), then unpack them back onto
// the parameter set. The AmpEnvelope was snapshotted at grab (dragStartEnv_) so the
// delta is absolute.
// pure envelope_edit inverse map, clamped), then unpack them back onto the parameter
// set. The StageEnvelope was snapshotted at grab (dragStartEnv_) so the delta is
// absolute.
const std::int64_t frames = dragSampleFrames_;
const double rate = liveSampleRate();
if (frames <= 0 || rate <= 0.0) return;
const double totalSeconds = static_cast<double>(frames) / rate;
const AmpEnvelope edited = resolveNodeDrag(dragStartEnv_, envNode_, overlay, totalSeconds,
envClampBounds(), dx, y - dragStartY_);
unpackEnvelope(edited, frames, dragStartFrame_, params_.play);
// In Gate the node IS a live AHDSR control, so the sounding note follows the drag;
// Trigger's nodes rewrite the play span and still commit on release.
const StageEnvelope edited = resolveNodeDrag(dragStartEnv_, envNode_, overlay,
totalSeconds, envClampBounds(), dx,
y - dragStartY_);
unpackEnvelope(overlayEnv_, edited, params_.play);
if (dragCommitsLive(DragKind::kEnvNode)) commitLive();
invalidate(); // live feedback; commit on WM_LBUTTONUP
return;
+36
View File
@@ -156,6 +156,42 @@ inline void drawKnobFace(LICE_IBitmap* bmp, const instrument::ui::Rect& knobRect
toLice(ui::roleColor(needleRole)), 1.0f, 0, true);
}
// The concentric INNER dial: a second value on the same cell, drawn in the categorical
// tertiary accent so it reads as a different KIND of control rather than a louder one — the
// same purple the overlay traces the envelope in, which is what ties a segment's knot to its
// dial by eye. Shares the outer knob's value<->angle map (param_slider's), so both needles
// point the same way for the same normalized value.
inline void drawInnerDial(LICE_IBitmap* bmp, const instrument::ui::Rect& innerRect,
double value01, ui::InteractionState st) {
using instrument::ui::KnobArc;
using instrument::ui::KnobGeometry;
using instrument::ui::KnobPoint;
const KnobGeometry kg = instrument::ui::computeKnob(innerRect);
if (kg.radius <= 1.0) return;
constexpr double kDegToRad = 3.14159265358979323846 / 180.0;
const KnobArc arc{};
const float cx = static_cast<float>(kg.centerX);
const float cy = static_cast<float>(kg.centerY);
const float r = static_cast<float>(kg.radius) - 0.5f;
const bool disabled = (st == ui::InteractionState::Disabled);
const bool hot = (st == ui::InteractionState::Dragging || st == ui::InteractionState::Hover);
LICE_FillCircle(bmp, cx, cy, r - 1.f, toLice(ui::roleColorState(ui::Role::BgPanel, st)), 1.0f,
0, true);
const double v = value01 < 0.0 ? 0.0 : (value01 > 1.0 ? 1.0 : value01);
const float a0 = static_cast<float>((arc.startDeg - 360.0) * kDegToRad);
const float av = static_cast<float>(
(arc.startDeg + v * instrument::ui::knobSweepDeg(arc) - 360.0) * kDegToRad);
const ui::Role arcRole = disabled ? ui::Role::TextDim
: (hot ? ui::Role::AccentHot : ui::Role::AccentTertiary);
LICE_Arc(bmp, cx, cy, r, a0, av, toLice(ui::roleColor(arcRole)), 1.0f, 0, true);
const KnobPoint tip = instrument::ui::knobNeedlePoint(kg, arc, v);
LICE_Line(bmp, static_cast<int>(cx + 0.5f), static_cast<int>(cy + 0.5f),
static_cast<int>(tip.x + 0.5f), static_cast<int>(tip.y + 0.5f),
toLice(ui::roleColor(disabled ? ui::Role::TextDim : ui::Role::AccentTertiary)),
1.0f, 0, true);
}
#endif // _WIN32
} // namespace reasampler::vst
+46 -6
View File
@@ -60,11 +60,13 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
case ParamControl::kDecay: return "Decay";
case ParamControl::kSustain: return "Sustain";
case ParamControl::kRelease: return "Release";
case ParamControl::kTrigFadeIn: return "Fade In";
case ParamControl::kTrigLength: return "Len %";
case ParamControl::kTrigFadeOut: return "Fade Out";
case ParamControl::kTrigAttack: return "Attack";
case ParamControl::kTrigHold: return "Hold";
case ParamControl::kTrigDecay: return "Decay";
case ParamControl::kKeyTrack: return "Key Trk";
case ParamControl::kPitchEnvAttack: return "P.Att";
case ParamControl::kPitchEnvHold: return "P.Hold";
case ParamControl::kPitchEnvDecay: return "P.Dec";
case ParamControl::kPitchEnvDepth: return "P.Depth";
case ParamControl::kVoiceCount: return "Voices";
@@ -81,6 +83,9 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
case ParamControl::kFilterEnvDecay: return "F.Dec";
case ParamControl::kFilterEnvSustain: return "F.Sus";
case ParamControl::kFilterEnvRelease: return "F.Rel";
case ParamControl::kFilterTrigAttack: return "F.Att";
case ParamControl::kFilterTrigHold: return "F.Hold";
case ParamControl::kFilterTrigDecay: return "F.Dec";
default: return "";
}
};
@@ -103,6 +108,22 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
}
kitText(bmp, g.caption, caption, Font::Micro, Role::TextDim);
// The overlay-select radio: filled in the tertiary accent (the colour the overlay
// traces in) when this group's envelope is the one on the waveform, hollow otherwise.
if (g.captionRadio.id >= 0) {
const bool on = (overlayEnv_ == overlayEnvForRadio(g.captionRadio.id));
const bool hov = isHovered(HoverKind::kEnvRadio, g.captionRadio.id);
const Rect& rb = g.captionRadio.box;
LICE_DrawRect(bmp, rb.x, rb.y, rb.width - 1, rb.height - 1,
toLice(roleColor(on || hov ? Role::AccentTertiary
: Role::LineHairline)),
1.0f, 0);
if (on) {
LICE_FillRect(bmp, rb.x + 3, rb.y + 3, rb.width - 6, rb.height - 6,
toLice(roleColor(Role::AccentTertiary)), 1.0f, 0);
}
}
// The compact caption toggle (right-anchored in the caption row, never full-width).
if (g.captionToggle.id >= 0) {
switch (static_cast<ParamControl>(g.captionToggle.id)) {
@@ -142,7 +163,7 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
// The knobs. A dependent group's knobs draw Disabled (not hidden) — stable geometry.
// The predicate is the input side's, so the drawn state and the inert grab agree.
for (const DeckCellLayout& c : g.cells) {
if (c.id < 0) continue; // reserved blank cell (the Trigger face's two spares)
if (c.id < 0) continue; // reserved blank cell (the Trigger face's spare)
const bool disabled = deckKnobDisabled(c.id);
const bool dragging = (drag_ == DragKind::kDeckKnob && dragParamId_ == c.id);
const bool hov = !disabled && isHovered(HoverKind::kControl, c.id);
@@ -151,9 +172,28 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
: (dragging ? InteractionState::Dragging
: (hov ? InteractionState::Hover : InteractionState::Rest));
drawKnobFace(bmp, c.knob, deckControlNorm(c.id), st);
const std::string label = (dragging || hov)
? deckValueLabel(c.id)
: std::string(knobName(static_cast<ParamControl>(c.id)));
// The inner dial rides only the knobs whose stage is sloped — deck_groups owns
// that rule, so a Hold or Sustain cell simply has no curve id and draws none.
const ParamControl curve = curveParamFor(static_cast<ParamControl>(c.id));
const bool innerDragging =
(drag_ == DragKind::kDeckKnob && dragInnerCellId_ == c.id);
const bool innerHov = !disabled && isHovered(HoverKind::kInnerDial, c.id);
if (curve != ParamControl::kCount) {
const InteractionState ist =
disabled ? InteractionState::Disabled
: (innerDragging ? InteractionState::Dragging
: (innerHov ? InteractionState::Hover
: InteractionState::Rest));
drawInnerDial(bmp, c.inner, deckControlNorm(static_cast<int>(curve)), ist);
}
// One label band, so the inner dial's readout takes it while the inner dial is the
// one being touched.
std::string label;
if (innerDragging || innerHov) label = deckValueLabel(static_cast<int>(curve));
else if (dragging || hov) label = deckValueLabel(c.id);
else label = std::string(knobName(static_cast<ParamControl>(c.id)));
kitTextCentered(bmp, c.label, label.c_str(), Font::Micro, Role::TextDim);
}
}
+28 -16
View File
@@ -100,37 +100,49 @@ void ReaSamplerEditor::paintWaveform(LICE_IBitmap* bmp, const Rect& band) {
void ReaSamplerEditor::paintEnvelopeOverlay(LICE_IBitmap* bmp, const OverlayArea& waveArea,
std::int64_t frames) {
if (overlayEnv_ == OverlayEnv::kNone) return; // no envelope selected is a resting state
const Rect& area = waveArea.rect;
if (frames <= 0 || area.width <= 0 || area.height <= 0) return;
const double rate = liveSampleRate();
if (rate <= 0.0) return;
const double totalSeconds = static_cast<double>(frames) / rate;
const std::int64_t startFrame = params_.startPoint.value_or(0);
const AmpEnvelope env = packEnvelope(params_.play, frames, startFrame);
const StageEnvelope env = packEnvelope(overlayEnv_, params_.play, frames, startFrame);
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, waveArea, totalSeconds);
// Trace the polyline in the categorical secondary accent (teal) so it reads as a distinct
// curve over the waveform. Clip x to the wave rect (a Gate release tail maps past the right).
const LICE_pixel line = toLice(roleColor(Role::AccentSecondary));
for (std::size_t i = 1; i < poly.size(); ++i) {
const int x0 = (std::max)(area.x, (std::min)(area.right() - 1, poly[i - 1].x));
const int x1 = (std::max)(area.x, (std::min)(area.right() - 1, poly[i].x));
LICE_Line(bmp, x0, poly[i - 1].y, x1, poly[i].y, line, 1.0f, 0, true);
// Trace the polyline in the categorical TERTIARY accent (purple): the waveform behind it is
// drawn in the primary lime, and the secondary teal this used to use sits too close to that
// hue to separate from it. Clip x to the wave rect. Knots are handles, not line vertices.
const LICE_pixel line = toLice(roleColor(Role::AccentTertiary));
const EnvVertex* prev = nullptr;
for (const EnvVertex& v : poly) {
if (v.knot) continue;
if (prev != nullptr) {
const int x0 = (std::max)(area.x, (std::min)(area.right() - 1, prev->x));
const int x1 = (std::max)(area.x, (std::min)(area.right() - 1, v.x));
LICE_Line(bmp, x0, prev->y, x1, v.y, line, 1.0f, 0, true);
}
prev = &v;
}
// Draggable node handles: a small square per draggable node (Origin + ReleaseStart are
// draw-only). Lit accent-hot when this node is the grabbed one. Every vertex is
// guaranteed in-bounds (edge nodes like ReleaseEnd at area.right()-1 must get handles);
// the handle square is additionally clamped inside the band so a 6px box on an edge
// node never overhangs into the neighbouring bands.
const LICE_pixel handle = toLice(roleColor(Role::AccentPrimary));
// Handles: a square per draggable stage node, a ROUND knot per curvable segment. Lit
// accent-hot when this node is the grabbed one. Every vertex is guaranteed in-bounds; the
// handle is additionally clamped inside the band so one on an edge node never overhangs
// into the neighbouring bands.
const LICE_pixel handle = toLice(roleColor(Role::AccentTertiary));
const LICE_pixel handleHot = toLice(roleColor(Role::AccentHot));
for (const EnvVertex& v : poly) {
if (v.node == EnvNode::Origin || v.node == EnvNode::ReleaseStart) continue;
if (v.node == EnvNode::Origin || v.node == EnvNode::ReleaseEnd) continue;
const bool grabbed = (drag_ == DragKind::kEnvNode && envNode_ == v.node);
const int r = 3;
const int hx = (std::max)(area.x + r, (std::min)(area.right() - 1 - r, v.x));
const int hy = (std::max)(area.y + r, (std::min)(area.bottom() - 1 - r, v.y));
LICE_FillRect(bmp, hx - r, hy - r, 2 * r, 2 * r, grabbed ? handleHot : handle, 1.0f, 0);
if (v.knot) {
LICE_FillCircle(bmp, static_cast<float>(hx), static_cast<float>(hy),
static_cast<float>(r), grabbed ? handleHot : handle, 1.0f, 0, true);
} else {
LICE_FillRect(bmp, hx - r, hy - r, 2 * r, 2 * r, grabbed ? handleHot : handle, 1.0f,
0);
}
}
}
+1
View File
@@ -244,6 +244,7 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
}
self->drag_ = DragKind::kNone;
self->dragParamId_ = -1;
self->dragInnerCellId_ = -1; // inner-dial drag state (peer reset)
self->curvePointIndex_ = -1; // curve-node drag state (peer reset)
// No cursor position is available here to re-resolve hover (unlike
// onMouseUp's release coordinates), so clear rather than leave it naming
+1 -1
View File
@@ -151,7 +151,7 @@ bool ReaSamplerEditor::dragCommitsLive(DragKind kind, int paramId) const {
const LiveDragKind k = kind == DragKind::kDeckKnob ? LiveDragKind::kDeckKnob
: kind == DragKind::kEnvNode ? LiveDragKind::kEnvNode
: LiveDragKind::kOther;
return instrument::ui::liveCommitFor(k, paramId, params_.play.playMode);
return instrument::ui::liveCommitFor(k, paramId);
}
void ReaSamplerEditor::loadSelection(const std::string& id) {
+32 -20
View File
@@ -17,7 +17,7 @@
#include "core/instrument/ui/deck_groups.h" // DeckParam / DeckGroupId / sampleDeckGroups
#include "core/instrument/ui/editor_geometry.h" // Rect (shared sub-rect type)
#include "core/instrument/ui/envelope_edit.h" // EnvClampBounds / NodeHit (envelope node hit-test/edit)
#include "core/instrument/ui/envelope_overlay.h" // AmpEnvelope / EnvNode (envelope overlay draw seam)
#include "core/instrument/ui/envelope_overlay.h" // StageEnvelope / EnvNode (envelope overlay draw seam)
#include "core/instrument/ui/knob_deck.h" // DeckGroupDesc / DeckLayout (the deck band)
#include "core/instrument/ui/sample_bands.h" // SampleBands (the band-stack allocator)
#include "core/instrument/ui/sample_chrome.h" // ChromeRects (chrome-band interior)
@@ -41,7 +41,6 @@ using instrument::map::PlaySeconds;
using instrument::map::SampleChoice;
using instrument::map::SampleRefEntry;
using instrument::map::SampleRefs;
using instrument::ui::AmpEnvelope;
using instrument::ui::ChromeRects;
using instrument::ui::DeckGroupDesc;
using instrument::ui::EnvClampBounds;
@@ -49,6 +48,7 @@ using instrument::ui::EnvNode;
using instrument::ui::OverlayArea;
using instrument::ui::Rect;
using instrument::ui::SampleBands;
using instrument::ui::StageEnvelope;
class ReaSamplerProcessor;
@@ -81,6 +81,11 @@ private:
enum class DragKind { kNone, kRootMarker, kWaveMarker, kScrollThumb, kEnvNode,
kCurveNode, kDeckKnob };
// Which envelope the waveform overlay is drawing and editing. Exclusive, and kNone is a
// valid resting state — the editor opens there. Transient view state: never persisted,
// never a parameter.
enum class OverlayEnv { kNone, kAmp, kPitch, kFilter };
// Controls on the setup surface. The int value is the opaque control id the pure
// knob_deck hit-test returns; the shell maps it to the one parameter set or a
// processor-side per-instance setter. The id space and the deck's group composition are
@@ -107,6 +112,8 @@ private:
kChanStereo, // the stereo channel-mode segment
kPreview, // the preview-trigger button
kControl, // a knob-deck element (index = control id)
kInnerDial, // a knob cell's inner curve dial (index = the OUTER control id)
kEnvRadio, // an envelope deck's overlay-select radio (index = radio control id)
kCurveNode, // a velocity-curve control point (index = point index)
kVelKnob, // the chrome preview-velocity radial knob
kStripKey, // a piano-strip key (index = MIDI note); carries the name tooltip
@@ -302,8 +309,8 @@ private:
// pitch envelope) — wall-clock seconds, rate-free; the build resolves to frames.
// The normalized [0,1] display value for control `id` given `play` (seconds -> 0..1 over
// a fixed ceiling, sustain 0..1 as-is, %-length/fade frames -> 0..1, semitone depth
// centered at 0.5).
// a fixed ceiling, levels and fractions as-is, semitone depth centered at 0.5, curve
// exponents over their logarithmic travel).
double controlValue(int id, const PlaySeconds& play) const;
// Applies a committed control interaction to `play`: a knob's normalized `value` or a
@@ -315,22 +322,22 @@ private:
// over the knob's 0..1).
void applyParamControl(int id, double value, int segment);
// The Trigger fade-in/out knob full-scale, in source frames: kFadeMaxSeconds resolved
// against the live rate — never a baked-in rate. Returns 0 when the rate is unknown.
double fadeMaxFrames() const;
// The overlay speaks one StageEnvelope whichever envelope is active; pack/unpack are the
// only place that knows which stored struct each `which` maps onto, so the drawn shape and
// a committed node drag can never disagree about it. AHDSR seconds are rate-free and copy
// 1-to-1; an AHD additionally needs the wall-clock span its Hold fraction is taken against,
// which is where `frames`/`startFrame` and the live rate come in.
// envelope_overlay's AmpEnvelope stores Trigger fades as fractions of the played span,
// while the parameter set stores source frames — pack/unpack own that conversion (see
// envelope_overlay.h's trigger-seam note). `frames` is total source frames; AHDSR
// seconds are rate-free and copy 1-to-1.
// PACK (draw): play params -> StageEnvelope. `startFrame` is the effective start point.
StageEnvelope packEnvelope(OverlayEnv which, const PlaySeconds& play, std::int64_t frames,
std::int64_t startFrame) const;
// PACK (draw): play params -> AmpEnvelope. `startFrame` is the effective start point.
AmpEnvelope packEnvelope(const PlaySeconds& play, std::int64_t frames,
std::int64_t startFrame) const;
// UNPACK (commit): an edited StageEnvelope -> the play params, in place.
void unpackEnvelope(OverlayEnv which, const StageEnvelope& env, PlaySeconds& play) const;
// UNPACK (commit): an edited AmpEnvelope -> the play params, in place.
void unpackEnvelope(const AmpEnvelope& env, std::int64_t frames, std::int64_t startFrame,
PlaySeconds& play) const;
// The radio control id that selects `which`, and its inverse. One table, so the deck's
// radio and the overlay can never drift apart.
static OverlayEnv overlayEnvForRadio(int radioId);
// Clamp bounds envelope_edit uses, matching the sliders' own domains so a node drag can
// never produce a param a slider couldn't.
@@ -417,16 +424,21 @@ private:
WaveMarker waveMarker_ = WaveMarker::kStart;
SetupMarkers dragStartMarkers_;
std::int64_t dragSampleFrames_ = 0; // decoded length of the sample under the drag
std::int64_t dragStartFrame_ = 0; // effective start point at grab time; for env-node drag
// Scrollbar-thumb drag: the offset at grab time. kDeckKnob drag: which control id.
int dragStartScrollOffset_ = 0;
int dragParamId_ = -1; // control id under a kDeckKnob drag; -2 = preview-vel knob
// The cell whose INNER dial is under a kDeckKnob drag (dragParamId_ then holds the curve
// control), so the paint side can light the right ring. -1 when the grab was the outer knob.
int dragInnerCellId_ = -1;
// Envelope-node drag: which node + the AmpEnvelope snapshotted at grab (absolute-delta
// Which envelope the overlay draws and edits (kNone = none, the opening state).
OverlayEnv overlayEnv_ = OverlayEnv::kNone;
// Envelope-node drag: which node + the StageEnvelope snapshotted at grab (absolute-delta
// contract, per envelope_edit's grabEnv).
EnvNode envNode_ = EnvNode::Origin;
AmpEnvelope dragStartEnv_{};
StageEnvelope dragStartEnv_{};
// Velocity-curve node drag: which point, the curve snapshotted at grab
// (resolvePointDrag's absolute-delta contract), and the grab-time box rect.