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
+72 -3
View File
@@ -15,6 +15,7 @@
#include "core/instrument/engine/envelopes.h"
#include "core/instrument/engine/filter/filter_params.h"
#include "core/instrument/engine/filter/voice_filter.h"
#include "core/instrument/engine/live_params.h"
#include "core/instrument/engine/pitch_shift.h"
#include "core/instrument/engine/play_params.h"
#include "core/instrument/engine/velocity_curve.h"
@@ -115,6 +116,16 @@ public:
// meaningful while active().
PitchEngine pitchEngine() const { return pitchEngine_; }
// 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.
//
// 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.
void applyLive(const instrument::engine::LiveValues& live, bool snap);
// Pre-sizes this voice's Preserve pitch shifters (both channels) to `windowFrames`, off
// the audio thread (allocates; also sizes the prime scratch buffer), so start() — which
// runs inside process() — never allocates. <= 1 leaves the shifters pass-through.
@@ -196,9 +207,10 @@ private:
}
// The cutoff position before the envelope: the stored knob position plus this note's
// velocity offset and key-tracking. Recomputed at note-on and at a legato retune (both
// move the note), never per frame.
void updateFilterCutoffBase(int note) {
// velocity offset and key-tracking. Evaluated at note-on, at a legato retune (both move
// the note), and when a live move changes the knob position or the key-track depth —
// never per frame.
double filterCutoffBaseTarget(int note) const {
double base = filterCutoffNorm_ + filterVelOffset_;
if (filterKeyTrack_ != 0.0 && sample_ != nullptr) {
base += filterKeyTrack_ *
@@ -207,10 +219,52 @@ private:
}
if (base < 0.0) base = 0.0;
if (base > 1.0) base = 1.0;
return base;
}
// Takes the base outright (no glide) — a note-on or a retune is a new note position, not a
// knob move, so there is nothing to glide from.
void updateFilterCutoffBase(int note) {
const double base = filterCutoffBaseTarget(note);
rBaseCutoff_.set(base);
filterBaseCutoff_ = static_cast<float>(base);
filterSolved_ = false; // forces the next frame to solve
}
// The full solve, from the tone-control ramps' current values, at the current base cutoff —
// the same shape start() performs, and it leaves the same solved-cutoff bookkeeping behind
// so an unmoved live block reproduces start()'s state exactly. State is preserved across
// prepare() by contract (voice_filter.h), which is what makes a live tone move glide
// rather than click.
void prepareFilterFromRamps() {
filterSettings_.resonanceNorm = static_cast<float>(rResonance_.value);
filterSettings_.morphNorm = static_cast<float>(rMorph_.value);
filterSettings_.driveNorm = static_cast<float>(rDrive_.value);
filterSettings_.cutoffNorm = filterBaseCutoff_;
filter_.prepare(filterSettings_, filterRate_);
filterSolvedCutoff_ = filterBaseCutoff_;
filterSolved_ = true;
}
// Advances the five live filter-control glides by one frame. Q, morph and drive are
// prepare()-cadence constants, so a move on any of them costs the full solve while the
// glide runs (~20 ms) and nothing once it lands; the base cutoff and the mod depth feed
// tickFilterCutoff's own cheap cutoff-only solve instead.
void tickFilterRamps() {
bool tone = false;
if (rResonance_.tick()) tone = true;
if (rMorph_.tick()) tone = true;
if (rDrive_.tick()) tone = true;
if (rBaseCutoff_.tick()) {
filterBaseCutoff_ = static_cast<float>(rBaseCutoff_.value);
filterSolved_ = false;
}
if (rModAmount_.tick()) filterModAmount_ = rModAmount_.value;
if (tone) prepareFilterFromRamps();
filterRamping_ = rResonance_.moving() || rMorph_.moving() || rDrive_.moving() ||
rBaseCutoff_.moving() || rModAmount_.moving();
}
// Seeds the takeover compensation on the first frame after a restart: the ramp is the
// actual discontinuity — (pre-cut reference - the new voice's raw output this frame) —
// applied ungated so the boundary frame reproduces the old level exactly.
@@ -397,6 +451,7 @@ private:
// Skipped whole when disengaged (the default), so an un-filtered render stays
// bit-identical to the pre-filter engine.
if (filterOn_) {
if (filterRamping_) tickFilterRamps(); // false at rest: one predicted branch
tickFilterCutoff();
outL = static_cast<double>(filter_.process(0, static_cast<float>(outL)));
// Dual-mono feeds channel 1 the value channel 0 already carried, so mirroring the
@@ -486,10 +541,24 @@ private:
double filterModAmount_ = 0.0;
double filterVelOffset_ = 0.0; // velAmount * velocityCurve.eval(velocity), fixed per note
double filterKeyTrack_ = 0.0;
instrument::engine::filter::FilterSettings filterSettings_{}; // the note's tone controls
float filterBaseCutoff_ = 1.0f; // cutoff before the envelope, clamped
float filterSolvedCutoff_ = 1.0f; // the position the live coefficients were solved from
bool filterSolved_ = false; // false forces the next frame to solve
// Live-parameter glides (live_params.h). Every one is parked at its target unless a move
// is in flight, so filterRamping_ is false and the per-sample path keeps the pre-live
// engine's exact shape. All five live in the filter's control domains — the envelopes
// need no ramp here, because holding normalized stage position is continuous by
// construction and their two genuine level steps are absorbed inside AdsrEnvelope /
// PitchEnvelope themselves.
bool filterRamping_ = false;
instrument::engine::ValueRamp rBaseCutoff_;
instrument::engine::ValueRamp rModAmount_;
instrument::engine::ValueRamp rResonance_;
instrument::engine::ValueRamp rMorph_;
instrument::engine::ValueRamp rDrive_;
// pitchEngine_ selects Varispeed (ratio bias) vs Preserve (source-rate read + shifter).
// shiftL_/shiftR_ transpose the Preserve output per channel. pitchEnv_ rides either engine.
//