S12: store wall-clock ADSR/pitch-env as seconds, resolve to frames at live rate

Kill kTier0Nominal*, adsrNeedsRateResolve, tier0Adsr, gateAdsr. Zones payload v5 carries
seconds; v3 legacy reads convert at the frozen authoring rate; v4 (branch-only) dropped.
Editor sliders now seconds. Engine takes frames resolved at keymap build.
This commit is contained in:
2026-07-27 02:51:46 -04:00
parent 1d338318e7
commit a54af277e4
10 changed files with 539 additions and 433 deletions
+30 -34
View File
@@ -298,19 +298,19 @@ int ReaSamplerEditor::upsertPickedOverride(const SetupMarkers& m) {
namespace {
// The S12/S15/S16 control-surface value DOMAINS (the shell owns these — param_slider is
// engine-free and maps only 0..1). Time sliders span [0, max] frames at the NOMINAL 44100 Hz
// rate; the stored frame count is host-rate-independent, so at other DAW rates the same slider
// position maps to a slightly different wall-clock duration. The ceiling is kept nominal-only
// because the host rate is not reachable inside the editor without a processor callback, and the
// approximation is musically negligible (±1-2 ms at typical rates). Build-time residual — one
// place to retune; not persisted.
constexpr double kEnvTimeMaxFrames = 2.0 * 44100.0; // AHDSR A/H/D/R + pitch A/D throw ceiling (44100 nominal)
// 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 zone stores; the keymap build
// resolves seconds->frames at the live rate. SOURCE-timeline fade sliders (Trigger fade-in/out)
// span [0, kFadeMaxFrames] SOURCE frames (a source-timeline quantity, PLAN.md §S15 — never a
// wall-clock second). Build-time residual — one place to retune; not persisted.
constexpr double kEnvTimeMaxSeconds = 2.0; // AHDSR A/H/D/R + pitch A/D throw ceiling (seconds)
constexpr double kFadeMaxFrames = 88200.0; // Trigger fade throw ceiling (source frames)
constexpr double kPitchDepthMaxSemis = 24.0; // AD pitch depth throw: +/-24 st, centered
double clamp01(double v) { return v < 0.0 ? 0.0 : (v > 1.0 ? 1.0 : v); }
} // namespace
std::vector<ControlDesc> ReaSamplerEditor::controlDescs(const ZonePlayParams& play) const {
std::vector<ControlDesc> ReaSamplerEditor::controlDescs(const ZonePlaySeconds& play) const {
std::vector<ControlDesc> out;
// Always: the two mode toggles.
out.push_back({static_cast<int>(ParamControl::kPlayMode), ControlKind::Toggle});
@@ -335,24 +335,27 @@ std::vector<ControlDesc> ReaSamplerEditor::controlDescs(const ZonePlayParams& pl
return out;
}
double ReaSamplerEditor::controlValue(int id, const ZonePlayParams& play) const {
double ReaSamplerEditor::controlValue(int id, const ZonePlaySeconds& play) const {
// Wall-clock seconds -> normalized over the seconds ceiling; source frames -> normalized over
// the frames ceiling. Two domains, kept explicit so neither leaks a rate.
const auto secToNorm = [](double s) { return clamp01(s / kEnvTimeMaxSeconds); };
const auto framesToNorm = [](std::int64_t f) {
return clamp01(static_cast<double>(f) / kEnvTimeMaxFrames);
return clamp01(static_cast<double>(f) / kFadeMaxFrames);
};
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;
case ParamControl::kAttack: return framesToNorm(play.adsr.attackFrames);
case ParamControl::kHold: return framesToNorm(play.adsr.holdFrames);
case ParamControl::kDecay: return framesToNorm(play.adsr.decayFrames);
case ParamControl::kAttack: return secToNorm(play.adsr.attackSeconds);
case ParamControl::kHold: return secToNorm(play.adsr.holdSeconds);
case ParamControl::kDecay: return secToNorm(play.adsr.decaySeconds);
case ParamControl::kSustain: return clamp01(play.adsr.sustainLevel);
case ParamControl::kRelease: return framesToNorm(play.adsr.releaseFrames);
case ParamControl::kRelease: return secToNorm(play.adsr.releaseSeconds);
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::kPitchEnvEnable:return play.pitchEnv.enabled ? 1.0 : 0.0;
case ParamControl::kPitchEnvAttack:return framesToNorm(play.pitchEnv.attackFrames);
case ParamControl::kPitchEnvDecay: return framesToNorm(play.pitchEnv.decayFrames);
case ParamControl::kPitchEnvAttack:return secToNorm(play.pitchEnv.attackSeconds);
case ParamControl::kPitchEnvDecay: return secToNorm(play.pitchEnv.decaySeconds);
case ParamControl::kPitchEnvDepth:
// Signed depth centered at 0.5 (0.5 == 0 semitones).
return clamp01(0.5 + play.pitchEnv.peakSemitones / (2.0 * kPitchDepthMaxSemis));
@@ -360,10 +363,11 @@ double ReaSamplerEditor::controlValue(int id, const ZonePlayParams& play) const
}
}
void ReaSamplerEditor::applyControl(int id, ZonePlayParams& play, double value,
void ReaSamplerEditor::applyControl(int id, ZonePlaySeconds& play, double value,
int segment) const {
const auto normToSec = [](double v) { return clamp01(v) * kEnvTimeMaxSeconds; };
const auto normToFrames = [](double v) {
return static_cast<std::int64_t>(clamp01(v) * kEnvTimeMaxFrames + 0.5);
return static_cast<std::int64_t>(clamp01(v) * kFadeMaxFrames + 0.5);
};
switch (static_cast<ParamControl>(id)) {
case ParamControl::kPlayMode:
@@ -372,11 +376,11 @@ void ReaSamplerEditor::applyControl(int id, ZonePlayParams& play, double value,
case ParamControl::kPitchEngine:
play.pitchEngine = (segment == 1) ? PitchEngine::Preserve : PitchEngine::Varispeed;
break;
case ParamControl::kAttack: play.adsr.attackFrames = normToFrames(value); break;
case ParamControl::kHold: play.adsr.holdFrames = normToFrames(value); break;
case ParamControl::kDecay: play.adsr.decayFrames = normToFrames(value); break;
case ParamControl::kAttack: play.adsr.attackSeconds = normToSec(value); break;
case ParamControl::kHold: play.adsr.holdSeconds = normToSec(value); break;
case ParamControl::kDecay: play.adsr.decaySeconds = normToSec(value); break;
case ParamControl::kSustain: play.adsr.sustainLevel = clamp01(value); break;
case ParamControl::kRelease: play.adsr.releaseFrames = normToFrames(value); break;
case ParamControl::kRelease: play.adsr.releaseSeconds = normToSec(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));
@@ -386,8 +390,8 @@ void ReaSamplerEditor::applyControl(int id, ZonePlayParams& play, double value,
case ParamControl::kPitchEnvEnable:
play.pitchEnv.enabled = (segment == 1);
break;
case ParamControl::kPitchEnvAttack: play.pitchEnv.attackFrames = normToFrames(value); break;
case ParamControl::kPitchEnvDecay: play.pitchEnv.decayFrames = normToFrames(value); break;
case ParamControl::kPitchEnvAttack: play.pitchEnv.attackSeconds = normToSec(value); break;
case ParamControl::kPitchEnvDecay: play.pitchEnv.decaySeconds = normToSec(value); break;
case ParamControl::kPitchEnvDepth:
play.pitchEnv.peakSemitones = (clamp01(value) - 0.5) * 2.0 * kPitchDepthMaxSemis;
break;
@@ -999,7 +1003,7 @@ void ReaSamplerEditor::paintControls(LICE_IBitmap* bmp, const Rect& panel) {
// PerformanceZone product defaults when the map is empty but a capture is picked
// (S15-F2 lean: the single-capture face shares the same storage site as a one-zone map;
// see paintZones for the gate that reaches here).
ZonePlayParams play;
ZonePlaySeconds play;
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
play = map_.zones[static_cast<std::size_t>(selectedZone_)].play;
} else if (map_.zones.empty() && !selectedId_.empty()) {
@@ -1284,7 +1288,7 @@ void ReaSamplerEditor::onMouseDown(int x, int y) {
// Synthesize a probe layout with the product defaults to see if the click is in the
// panel before committing to creating the zone.
const Rect panel = zonesControlPanel(bands);
const ZonePlayParams defaultPlay = PerformanceZone{}.play;
const ZonePlaySeconds defaultPlay = PerformanceZone{}.play;
const std::vector<ControlDesc> probeDescs = controlDescs(defaultPlay);
const std::vector<ControlRow> probeRows = layoutControls(panel, probeDescs);
if (controlAtPoint(probeRows, x, y) >= 0) {
@@ -1321,14 +1325,6 @@ void ReaSamplerEditor::onMouseDown(int x, int y) {
dragParamPanel_ = panel;
dragStartMap_ = map_;
applyControl(id, z.play, valueAtPoint(r.control, x), 0);
// An explicit ADSR slider touch commits a rate-resolved value (the slider
// maps 0..1 -> editor-domain frames at kEnvTimeMaxFrames, not nominal 44100-Hz
// counts). Mark the zone as no longer needing rate-resolve so buildZonedKeymap
// does not re-rescale the value at reload time.
if (id >= static_cast<int>(ParamControl::kAttack) &&
id <= static_cast<int>(ParamControl::kRelease)) {
z.adsrNeedsRateResolve = false;
}
invalidate(); // live feedback; commit on WM_LBUTTONUP
}
break;