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