instrument: deliver continuous playback params live to sounding voices via a seqlock block, holding normalized stage position across time edits

This commit is contained in:
2026-07-30 21:03:05 -04:00
parent 7bd911d58b
commit 1dade0bfcf
25 changed files with 1352 additions and 66 deletions
+190 -50
View File
@@ -12,6 +12,41 @@
namespace reasampler {
// Absorbs a step a live parameter move would otherwise put straight into an evaluator's
// output, as an offset that decays to EXACTLY zero — so the at-rest path carries no residue
// and the smoother's own branch stays predictably false. Per-frame decay rather than a
// wall-clock one, matching the voice's takeover declick; the floor is far below both domains
// this is used in (amplitude, and semitones of pitch offset).
class StepSmoother {
public:
// `step` is (level before the change - level after it): adding it back reproduces the
// pre-change output exactly on the first frame.
void absorb(double step) {
offset_ += step;
active_ = (offset_ > kFloor || offset_ < -kFloor);
if (!active_) offset_ = 0.0;
}
void clear() { offset_ = 0.0; active_ = false; }
bool active() const { return active_; }
// This frame's offset; decays afterwards, latching inactive at the floor.
double advance() {
const double out = offset_;
offset_ *= kDecay;
if (offset_ < kFloor && offset_ > -kFloor) {
offset_ = 0.0;
active_ = false;
}
return out;
}
private:
static constexpr double kDecay = 0.95;
static constexpr double kFloor = 1e-5;
double offset_ = 0.0;
bool active_ = false;
};
// AHDSR amplitude envelope, sample-based (times in frames), linear segments. A gate:
// noteOn() enters Attack; noteOff() enters Release from wherever it is.
//
@@ -24,6 +59,11 @@ namespace reasampler {
// A zero-length attack jumps straight to 1 on the first frame; holdFrames == 0 skips Hold
// entirely (the pre-hold-stage ADSR, back-compat); zero decay jumps to sustain; a noteOff
// during attack/hold/decay releases from the current partial level, not from sustainLevel.
//
// stagePos_ is the elapsed position within the current stage. It is a double rather than a
// frame count only so applyLive can hold a fractional normalized position; every value it
// takes on the un-edited path is integral, so the segment math is bit-identical to the
// integer-counter engine.
class AdsrEnvelope {
public:
enum class Stage { Idle, Attack, Hold, Decay, Sustain, Release, Finished };
@@ -34,18 +74,43 @@ public:
void noteOn() {
stage_ = Stage::Attack;
level_ = 0.0;
framesInStage_ = 0;
stagePos_ = 0.0;
smooth_.clear();
}
// Gate off: enter Release from the CURRENT level — release-before-sustain releases from
// the partial attack/decay level, not from sustainLevel.
// the partial attack/decay level, not from sustainLevel. A running smoother deliberately
// survives: it is mid-glide, and cutting it here would reintroduce the step it absorbed.
void noteOff() {
if (stage_ == Stage::Idle || stage_ == Stage::Finished || stage_ == Stage::Release) {
return; // already released / not sounding.
}
releaseFrom_ = level_;
stage_ = Stage::Release;
framesInStage_ = 0;
stagePos_ = 0.0;
}
// 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
// newly-dialled duration. The rule is expressed over normalized position, never over
// output level, so a per-segment curve exponent composes with it as a pure map of phi.
//
// Two cases phi cannot cover, both absorbed by the smoother rather than allowed to step:
// a sustain level moved while the voice holds it (sustain is a level, not a timed stage),
// and a stage duration dialled to exactly zero mid-stage (the stage ceases to exist and
// completes at its terminal level).
void applyLive(const AdsrParams& params) {
const double before = stageLevel(params_);
const double oldDuration = stageDuration(params_);
const double newDuration = stageDuration(params);
if (newDuration > 0.0) {
stagePos_ = (oldDuration > 0.0) ? stagePos_ * (newDuration / oldDuration)
: newDuration; // a collapsed stage was complete
}
params_ = params;
const double after = stageLevel(params_);
if (after != before) smooth_.absorb(before - after);
}
// Advances one frame and returns the amplitude for THIS frame (before advancing).
@@ -53,6 +118,59 @@ public:
// the next noteOn). A single, monotonic per-frame step — the caller pulls one value per
// output frame.
double tick() {
const double out = tickStage();
return smooth_.active() ? out + smooth_.advance() : out;
}
Stage stage() const { return stage_; }
bool finished() const { return stage_ == Stage::Finished; }
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.
double stageLevel(const AdsrParams& params) const {
switch (stage_) {
case Stage::Attack: {
if (params.attackFrames <= 0) return 1.0;
const double l = stagePos_ / static_cast<double>(params.attackFrames);
return l > 1.0 ? 1.0 : l;
}
case Stage::Hold:
// A zero-length hold falls straight through to Decay on the next tick, whose
// level at position 0 is 1.0 — unless decay is zero too, which lands on sustain.
if (params.holdFrames > 0) return 1.0;
return (params.decayFrames <= 0) ? params.sustainLevel : 1.0;
case Stage::Decay: {
if (params.decayFrames <= 0) return params.sustainLevel;
const double t = stagePos_ / static_cast<double>(params.decayFrames);
return 1.0 + (params.sustainLevel - 1.0) * t;
}
case Stage::Sustain:
return params.sustainLevel;
case Stage::Release: {
if (params.releaseFrames <= 0) return 0.0;
const double t = stagePos_ / static_cast<double>(params.releaseFrames);
const double l = releaseFrom_ * (1.0 - t);
return l < 0.0 ? 0.0 : l;
}
default:
return 0.0;
}
}
// The current stage's dialled duration under `params`; 0 for the untimed stages.
double stageDuration(const AdsrParams& params) const {
switch (stage_) {
case Stage::Attack: return static_cast<double>(params.attackFrames);
case Stage::Hold: return static_cast<double>(params.holdFrames);
case Stage::Decay: return static_cast<double>(params.decayFrames);
case Stage::Release: return static_cast<double>(params.releaseFrames);
default: return 0.0;
}
}
double tickStage() {
switch (stage_) {
case Stage::Idle:
case Stage::Finished:
@@ -63,16 +181,15 @@ public:
if (params_.attackFrames <= 0) {
level_ = 1.0;
} else {
level_ = static_cast<double>(framesInStage_) /
static_cast<double>(params_.attackFrames);
level_ = stagePos_ / static_cast<double>(params_.attackFrames);
if (level_ > 1.0) level_ = 1.0;
}
const double out = level_;
++framesInStage_;
if (framesInStage_ >= params_.attackFrames) {
stagePos_ += 1.0;
if (stagePos_ >= static_cast<double>(params_.attackFrames)) {
// holdFrames == 0 falls straight through Hold on the next tick to Decay.
stage_ = Stage::Hold;
framesInStage_ = 0;
stagePos_ = 0.0;
level_ = 1.0;
}
return out;
@@ -84,18 +201,19 @@ public:
// extra sample.
if (params_.holdFrames <= 0) {
stage_ = Stage::Decay;
framesInStage_ = 0;
stagePos_ = 0.0;
level_ = 1.0;
// Single re-dispatch into Decay (bounded: Hold->Decay only, not general
// recursion).
return tick();
// recursion). Re-enters the STAGE evaluator, never tick(), so a running
// smoother is applied exactly once per frame.
return tickStage();
}
level_ = 1.0;
const double out = level_;
++framesInStage_;
if (framesInStage_ >= params_.holdFrames) {
stagePos_ += 1.0;
if (stagePos_ >= static_cast<double>(params_.holdFrames)) {
stage_ = Stage::Decay;
framesInStage_ = 0;
stagePos_ = 0.0;
level_ = 1.0;
}
return out;
@@ -105,15 +223,14 @@ public:
if (params_.decayFrames <= 0) {
level_ = params_.sustainLevel;
} else {
const double t = static_cast<double>(framesInStage_) /
static_cast<double>(params_.decayFrames);
const double t = stagePos_ / static_cast<double>(params_.decayFrames);
level_ = 1.0 + (params_.sustainLevel - 1.0) * t;
}
const double out = level_;
++framesInStage_;
if (framesInStage_ >= params_.decayFrames) {
stagePos_ += 1.0;
if (stagePos_ >= static_cast<double>(params_.decayFrames)) {
stage_ = Stage::Sustain;
framesInStage_ = 0;
stagePos_ = 0.0;
level_ = params_.sustainLevel;
}
return out;
@@ -129,13 +246,12 @@ public:
stage_ = Stage::Finished;
return 0.0;
}
const double t = static_cast<double>(framesInStage_) /
static_cast<double>(params_.releaseFrames);
const double t = stagePos_ / static_cast<double>(params_.releaseFrames);
level_ = releaseFrom_ * (1.0 - t);
if (level_ < 0.0) level_ = 0.0;
const double out = level_;
++framesInStage_;
if (framesInStage_ >= params_.releaseFrames) {
stagePos_ += 1.0;
if (stagePos_ >= static_cast<double>(params_.releaseFrames)) {
stage_ = Stage::Finished;
level_ = 0.0;
}
@@ -145,16 +261,12 @@ public:
return 0.0; // unreachable; silences a warning.
}
Stage stage() const { return stage_; }
bool finished() const { return stage_ == Stage::Finished; }
double level() const { return level_; }
private:
AdsrParams params_;
Stage stage_ = Stage::Idle;
double level_ = 0.0;
std::int64_t framesInStage_ = 0;
double stagePos_ = 0.0;
double releaseFrom_ = 0.0; // level at the moment noteOff() was called
StepSmoother smooth_;
};
// A stateless-shape amplitude function over the play span, evaluated at a source-frame
@@ -233,34 +345,62 @@ private:
// (Varispeed) or a shift-amount add (Preserve).
class PitchEnvelope {
public:
void configure(const PitchEnvParams& params) { params_ = params; pos_ = 0; }
void noteOn() { pos_ = 0; }
void configure(const PitchEnvParams& params) { params_ = params; pos_ = 0.0; }
void noteOn() { pos_ = 0.0; 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
// deliberately not a parameter here.
void applyLive(std::int64_t attackFrames, std::int64_t decayFrames,
double peakSemitones) {
const double before = offsetAt(params_);
const double a = params_.attackFrames > 0 ? static_cast<double>(params_.attackFrames) : 0.0;
const double d = params_.decayFrames > 0 ? static_cast<double>(params_.decayFrames) : 0.0;
const double na = attackFrames > 0 ? static_cast<double>(attackFrames) : 0.0;
const double nd = decayFrames > 0 ? static_cast<double>(decayFrames) : 0.0;
if (pos_ < a) {
pos_ = (na > 0.0) ? pos_ * (na / a) : na;
} else if (pos_ < a + d) {
pos_ = (nd > 0.0) ? na + (pos_ - a) * (nd / d) : na + nd;
} else {
pos_ = na + nd; // already past the envelope: stay past it under the new times
}
params_.attackFrames = attackFrames;
params_.decayFrames = decayFrames;
params_.peakSemitones = peakSemitones;
const double after = offsetAt(params_);
if (after != before) smooth_.absorb(before - after);
}
double tick() {
if (!params_.enabled) return 0.0;
const std::int64_t a = params_.attackFrames > 0 ? params_.attackFrames : 0;
const std::int64_t d = params_.decayFrames > 0 ? params_.decayFrames : 0;
const double peak = params_.peakSemitones;
double offset;
if (pos_ < a) {
// Attack: 0 -> peak over attackFrames (rise into the peak).
offset = peak * (static_cast<double>(pos_) / static_cast<double>(a));
} else if (pos_ < a + d) {
// Decay: peak -> 0 over decayFrames (settle to base pitch).
const double t = static_cast<double>(pos_ - a) / static_cast<double>(d);
offset = peak * (1.0 - t);
} else {
offset = 0.0; // past attack+decay: at base pitch forever.
}
++pos_;
return offset;
const double offset = offsetAt(params_);
pos_ += 1.0;
return smooth_.active() ? offset + smooth_.advance() : offset;
}
private:
// The semitone offset at the current position under `params` — the shared evaluator for
// both tick() and applyLive's before/after comparison.
double offsetAt(const PitchEnvParams& params) const {
if (!params.enabled) return 0.0;
const double a = params.attackFrames > 0 ? static_cast<double>(params.attackFrames) : 0.0;
const double d = params.decayFrames > 0 ? static_cast<double>(params.decayFrames) : 0.0;
if (pos_ < a) {
// Attack: 0 -> peak over attackFrames (rise into the peak).
return params.peakSemitones * (pos_ / a);
}
if (pos_ < a + d) {
// Decay: peak -> 0 over decayFrames (settle to base pitch).
return params.peakSemitones * (1.0 - (pos_ - a) / d);
}
return 0.0; // past attack+decay: at base pitch forever.
}
PitchEnvParams params_;
std::int64_t pos_ = 0;
double pos_ = 0.0;
StepSmoother smooth_;
};
} // namespace reasampler