instrument: snap live params onto a fresh voice, roll a live drag back on capture loss, serialize the seqlock's two writers

This commit is contained in:
2026-07-30 21:39:56 -04:00
parent 1dade0bfcf
commit bbc7dc70bb
15 changed files with 621 additions and 143 deletions
+35 -19
View File
@@ -90,6 +90,17 @@ public:
stagePos_ = 0.0;
}
// Live parameter delivery to a fresh voice — one that has NOT yet rendered a frame, whose
// latched copy may predate the newest edit. It takes the params outright: there is no
// phase to hold and nothing to be continuous with. applyLive cannot serve here in either
// direction — with a stale duration of 0 its phi rule reads stagePos_ == 0 as a COMPLETED
// stage and discards the newly-dialled time, and with a stale duration > 0 against a new 0
// it absorbs a full-scale step into a voice that has emitted nothing, fading the onset in.
void snapLive(const AdsrParams& params) {
params_ = params;
smooth_.clear();
}
// Live parameter delivery to a SOUNDING voice. The mid-stage rule is HOLD NORMALIZED
// STAGE POSITION: phi = elapsed/duration is kept fixed across the change, so this frame's
// level is unchanged by construction and the remainder of the stage takes its share of the
@@ -117,6 +128,11 @@ public:
// Once Release completes the envelope latches Finished and returns 0.0 forever (until
// the next noteOn). A single, monotonic per-frame step — the caller pulls one value per
// output frame.
//
// While the smoother runs the return may sit OUTSIDE [0,1] by the offset it is decaying
// (bounded by the step it absorbed). finished() ignores that residue, so a Release that
// completes with an offset still decaying is hard-cut when the voice frees — the audible
// remainder of a step the smoother had already taken most of.
double tick() {
const double out = tickStage();
return smooth_.active() ? out + smooth_.advance() : out;
@@ -127,8 +143,11 @@ public:
double level() const { return level_; }
private:
// The level tick() would emit right now under `params`, without advancing anything — the
// prediction applyLive compares across the change to size the smoother.
// The level tick() would emit right now under `params` without advancing anything. THE one
// home for every segment's shape: tickStage owns only the advance and the stage
// transitions and reads its output from here, so a per-segment curve added later lands in
// one place and the smoother can never size a step against a different curve than the
// output takes.
double stageLevel(const AdsrParams& params) const {
switch (stage_) {
case Stage::Attack: {
@@ -178,12 +197,7 @@ private:
return 0.0;
case Stage::Attack: {
if (params_.attackFrames <= 0) {
level_ = 1.0;
} else {
level_ = stagePos_ / static_cast<double>(params_.attackFrames);
if (level_ > 1.0) level_ = 1.0;
}
level_ = stageLevel(params_);
const double out = level_;
stagePos_ += 1.0;
if (stagePos_ >= static_cast<double>(params_.attackFrames)) {
@@ -208,7 +222,7 @@ private:
// smoother is applied exactly once per frame.
return tickStage();
}
level_ = 1.0;
level_ = stageLevel(params_);
const double out = level_;
stagePos_ += 1.0;
if (stagePos_ >= static_cast<double>(params_.holdFrames)) {
@@ -220,12 +234,7 @@ private:
}
case Stage::Decay: {
if (params_.decayFrames <= 0) {
level_ = params_.sustainLevel;
} else {
const double t = stagePos_ / static_cast<double>(params_.decayFrames);
level_ = 1.0 + (params_.sustainLevel - 1.0) * t;
}
level_ = stageLevel(params_);
const double out = level_;
stagePos_ += 1.0;
if (stagePos_ >= static_cast<double>(params_.decayFrames)) {
@@ -237,7 +246,7 @@ private:
}
case Stage::Sustain:
level_ = params_.sustainLevel;
level_ = stageLevel(params_);
return level_;
case Stage::Release: {
@@ -246,9 +255,7 @@ private:
stage_ = Stage::Finished;
return 0.0;
}
const double t = stagePos_ / static_cast<double>(params_.releaseFrames);
level_ = releaseFrom_ * (1.0 - t);
if (level_ < 0.0) level_ = 0.0;
level_ = stageLevel(params_);
const double out = level_;
stagePos_ += 1.0;
if (stagePos_ >= static_cast<double>(params_.releaseFrames)) {
@@ -348,6 +355,15 @@ public:
void configure(const PitchEnvParams& params) { params_ = params; pos_ = 0.0; }
void noteOn() { pos_ = 0.0; smooth_.clear(); }
// Peer of AdsrEnvelope::snapLive (see it for why the two paths cannot share code): a voice
// that has rendered nothing takes the new times and depth outright.
void snapLive(std::int64_t attackFrames, std::int64_t decayFrames, double peakSemitones) {
params_.attackFrames = attackFrames;
params_.decayFrames = decayFrames;
params_.peakSemitones = peakSemitones;
smooth_.clear();
}
// Live parameter delivery, same rule as AdsrEnvelope::applyLive: hold the normalized
// position within whichever leg the envelope is in, and absorb the depth step (peak is a
// level, not a duration). `enabled` is a discrete toggle and travels by reload, so it is