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
+14 -1
View File
@@ -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
+16 -5
View File
@@ -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;
+2 -2
View File
@@ -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.