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:
@@ -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
|
||||
|
||||
@@ -52,6 +52,16 @@ LiveValues foldLive(const PlayParams& params);
|
||||
// reader: after the retry budget it reports "nothing new" and the caller keeps its last good
|
||||
// snapshot rather than spinning on the audio thread.
|
||||
//
|
||||
// SINGLE-WRITER IS THE CALLER'S JOB and is load-bearing: two concurrent writers can leave the
|
||||
// generation EVEN mid-write (A stores gen+1, B reads odd and stores gen+2) while both copy the
|
||||
// block, and a reader then accepts a torn block as coherent. Every publisher must serialize.
|
||||
//
|
||||
// The plain (non-atomic) block copied across the fences is the standard pragmatic seqlock:
|
||||
// the fences give correct ordering, but the concurrent read of a non-atomic object is a data
|
||||
// race under the C++ object model, so TSan/UBSan will report it. That report is expected, not
|
||||
// a defect — there is no clean lock-free standard-C++ alternative that keeps the block a plain
|
||||
// value the audio thread can copy in one shot.
|
||||
//
|
||||
// The writer interface deliberately assumes NO particular thread beyond single-writer, so a
|
||||
// host's own parameter-change queue (delivered on the audio thread with sample offsets) can
|
||||
// drive it later without a redesign.
|
||||
@@ -64,7 +74,10 @@ public:
|
||||
std::atomic_thread_fence(std::memory_order_release);
|
||||
values_ = values;
|
||||
std::atomic_thread_fence(std::memory_order_release);
|
||||
seq_.store(gen + 2, std::memory_order_release); // even: complete and coherent
|
||||
// Skip 0 on wrap (~2^31 publishes): landing there would read as "never published" and
|
||||
// stall every reader until the NEXT publish — a silent mode, unlike a loud one.
|
||||
const std::uint32_t next = (gen + 2 == 0u) ? 2u : gen + 2;
|
||||
seq_.store(next, std::memory_order_release); // even: complete and coherent
|
||||
}
|
||||
|
||||
// Copies the block into `out` and returns the generation actually observed, or 0 when
|
||||
|
||||
@@ -197,13 +197,24 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
|
||||
|
||||
void Voice::applyLive(const instrument::engine::LiveValues& live, bool snap) {
|
||||
// Gate's amplitude envelope is the AHDSR; Trigger's fade shape is anchored to a play span
|
||||
// resolved at note-on and is not a live control, so it is deliberately untouched here.
|
||||
if (playMode_ == PlayMode::Gate) env_.applyLive(live.adsr);
|
||||
pitchEnv_.applyLive(live.pitchEnvAttackFrames, live.pitchEnvDecayFrames,
|
||||
live.pitchEnvPeakSemitones);
|
||||
// resolved at note-on and travels by reload instead (deck_groups.h names why).
|
||||
//
|
||||
// A fresh note and a sounding one take DIFFERENT envelope entry points, never one with a
|
||||
// flag: a voice that has rendered nothing has no phase to hold and nothing to be
|
||||
// continuous with, and the mid-stage rule misreads its stage-0 position (envelopes.h).
|
||||
if (snap) {
|
||||
if (playMode_ == PlayMode::Gate) env_.snapLive(live.adsr);
|
||||
pitchEnv_.snapLive(live.pitchEnvAttackFrames, live.pitchEnvDecayFrames,
|
||||
live.pitchEnvPeakSemitones);
|
||||
} else {
|
||||
if (playMode_ == PlayMode::Gate) env_.applyLive(live.adsr);
|
||||
pitchEnv_.applyLive(live.pitchEnvAttackFrames, live.pitchEnvDecayFrames,
|
||||
live.pitchEnvPeakSemitones);
|
||||
}
|
||||
if (!filterOn_) return; // filter enable is a discrete toggle: it travels by reload
|
||||
|
||||
filterEnv_.applyLive(live.filterEnv);
|
||||
if (snap) filterEnv_.snapLive(live.filterEnv);
|
||||
else filterEnv_.applyLive(live.filterEnv);
|
||||
filterCutoffNorm_ = static_cast<double>(live.filterSettings.cutoffNorm);
|
||||
filterKeyTrack_ = live.filterKeyTrack;
|
||||
filterSettings_.morphLaw = live.filterSettings.morphLaw;
|
||||
|
||||
@@ -119,8 +119,8 @@ public:
|
||||
// Applies the live-parameter block to a voice that is already sounding (or, with `snap`,
|
||||
// to one just started). Called at BLOCK boundaries by VoiceEngine — never per frame — so
|
||||
// the per-sample shape is unchanged; every continuous control glides toward its new value
|
||||
// from here rather than jumping to it. `snap` takes the values outright: a fresh note has
|
||||
// nothing to glide from, and its latched copy may predate the newest edit.
|
||||
// from here rather than jumping to it. `snap` takes the values outright — glides AND
|
||||
// envelopes: a fresh note has nothing to glide from, and its copy may predate the edit.
|
||||
//
|
||||
// What is NOT here is the point: velocity and its curve result, the note number and the
|
||||
// pitch ratio, and the decoded PCM stay latched at note-on.
|
||||
|
||||
Reference in New Issue
Block a user