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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user