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
+12 -1
View File
@@ -16,12 +16,19 @@ reasampler_test(master_gain LINK master_gain)
# Declared before sampler_core because the voice now runs one per sounding note.
add_subdirectory(filter)
# The live-parameter block: the value layer plus its publication, deliberately linking no
# engine — the block is a plain value the voice observes, not a thing the engine owns.
reasampler_pure_library(live_params
SOURCES live_params.cpp
LINK PUBLIC peaks velocity_curve filter)
reasampler_test(live_params LINK live_params)
# Two TUs on the engine's own responsibility seam (per-note setup vs. note routing and
# block render). The per-sample render half stays inline in voice.h precisely so this TU
# boundary costs the hot path nothing.
reasampler_pure_library(sampler_core
SOURCES voice.cpp voice_engine.cpp
LINK PUBLIC peaks pitch_shift velocity_curve filter)
LINK PUBLIC peaks pitch_shift velocity_curve filter live_params)
# Links only sampler_core: linking more would break the plain-data-boundary proof — a VST3
# or REAPER type reaching the core would fail to compile or link here.
reasampler_test(sampler_core LINK sampler_core)
@@ -29,3 +36,7 @@ reasampler_test(sampler_core LINK sampler_core)
# The filter's own seams are covered by the four targets in filter/; this one covers the
# integration: pipeline order, per-voice independence, and the off-by-default bit-identity.
reasampler_test(sampler_filter LINK sampler_core)
# Live delivery is the third integration seam over the same engine: what a published block
# does to a voice that is already sounding, and what it must leave alone.
reasampler_test(live_delivery LINK sampler_core)
+190 -50
View File
@@ -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
@@ -234,6 +234,13 @@ topology.
genuinely ~0.02% low at 48 kHz, widening to ~0.03% low at 192 kHz — real coefficient
narrowing, not measurement-window noise, and comfortably inside the test's 0.4% tolerance
either way. Do not reintroduce a direct-form kernel.
- **A coefficient jump here produces no isolated output spike, measured.** Preserving state
across `prepare()` is strong enough that even an instantaneous cutoff/Q/morph jump leaves the
boundary frame inside the signal's own frame-to-frame range — a single-frame-spike metric
cannot detect one. What the caller's per-frame glide prevents is therefore the *parameter*
arriving as a step (and the zipper of repeated steps at control rate), not a click at the
jump itself. A test claiming to prove the glide must measure how fast the output diverges,
not how far one frame moves; `live_delivery_tests` does.
- **`prepare()` deliberately does not clear state** — a live parameter move must glide, not
click. Call `reset()` at note-on. **Exception: the non-positive-rate bypass path.** There,
`a1=1, a2=a3=0` makes both state updates the exact identity and `bypassMix()` never reads
@@ -0,0 +1,26 @@
// live_params.cpp — the fold from the parameter set to the live block, and the ramp-step law.
// See live_params.h for the publication contract.
#include "core/instrument/engine/live_params.h"
namespace reasampler::instrument::engine {
LiveValues foldLive(const PlayParams& params) {
LiveValues v;
v.filterSettings = params.filter.settings;
v.filterModAmount = params.filter.modAmount;
v.filterKeyTrack = params.filter.keyTrack;
v.filterEnv = params.filter.env;
v.adsr = params.adsr;
v.pitchEnvAttackFrames = params.pitchEnv.attackFrames;
v.pitchEnvDecayFrames = params.pitchEnv.decayFrames;
v.pitchEnvPeakSemitones = params.pitchEnv.peakSemitones;
return v;
}
double liveRampStep(double sampleRate) {
if (!(sampleRate > 0.0)) return 0.0; // also catches NaN
return 1.0 / (kLiveRampSeconds * sampleRate);
}
} // namespace reasampler::instrument::engine
+118
View File
@@ -0,0 +1,118 @@
#pragma once
// live_params.h — the live playback-parameter block: the plain value bundle the audio thread
// observes once per BLOCK, the single-writer seqlock that publishes it without a lock or a
// torn read, the ONE fold from PlayParams that keeps the two representations in step, and the
// per-frame ramp that keeps a block-rate step inaudible. Ownership belongs above every
// instrument snapshot (see SampleData::live).
#include <atomic>
#include <cstdint>
#include <type_traits>
#include "core/instrument/engine/play_params.h"
namespace reasampler::instrument::engine {
// Full-scale glide time for a live control move (wall-clock seconds), so every smoothed
// control in the program settles on the one time base the post-mixer gain ramp already uses.
inline constexpr double kLiveRampSeconds = 0.020;
// Every continuously-valued playback control, in the SAME domains the engine latches at
// note-on (normalized control positions, envelope times already resolved to frames). What is
// deliberately absent is as load-bearing as what is present: velocity and everything derived
// from it, the note number and its pitch ratio, and the decoded PCM are facts about the note
// event, not controls, and stay latched at note-on. The discrete toggles (play mode, pitch
// engine, filter enable/law, pitch-envelope enable, channel mode) travel by reload instead.
//
// morphLaw rides inside filterSettings only because it is cheaper to carry the whole struct to
// the filter's prepare() than to splice it back; it changes only across a reload, which
// republishes this block, so the two can never disagree.
struct LiveValues {
filter::FilterSettings filterSettings{};
double filterModAmount = 0.0;
double filterKeyTrack = 0.0;
AdsrParams filterEnv{};
AdsrParams adsr{};
std::int64_t pitchEnvAttackFrames = 0;
std::int64_t pitchEnvDecayFrames = 0;
double pitchEnvPeakSemitones = 0.0;
};
// The seqlock copies the block as raw bytes, which is only defensible for a plain value type.
static_assert(std::is_trivially_copyable_v<LiveValues>,
"the live block is copied under a seqlock — it must stay a plain value");
// The ONE derivation of the live block from the parameter set. Every publisher goes through
// here so there is a single site to keep in step with PlayParams.
LiveValues foldLive(const PlayParams& params);
// Single-writer / single-reader seqlock. The writer publishes a whole block between an odd
// and an even generation; the reader copies the block and re-checks the generation, retrying
// a bounded number of times, so it can never act on a half-applied edit. Wait-free for the
// reader: after the retry budget it reports "nothing new" and the caller keeps its last good
// snapshot rather than spinning on the audio thread.
//
// 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.
class LiveParams {
public:
// A generation of 0 means "never published"; the first publish lands on 2.
void publish(const LiveValues& values) {
const std::uint32_t gen = seq_.load(std::memory_order_relaxed);
seq_.store(gen + 1, std::memory_order_relaxed); // odd: a write is in progress
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
}
// Copies the block into `out` and returns the generation actually observed, or 0 when
// nothing has been published yet or the retry budget ran out (in which case `out` may hold
// a torn copy and MUST be discarded — compare the return against 0 before using it).
std::uint32_t read(LiveValues& out, int maxAttempts = 4) const {
for (int attempt = 0; attempt < maxAttempts; ++attempt) {
const std::uint32_t before = seq_.load(std::memory_order_acquire);
if (before == 0) return 0; // never published
if ((before & 1u) != 0u) continue; // writer mid-update
std::atomic_thread_fence(std::memory_order_acquire);
out = values_;
std::atomic_thread_fence(std::memory_order_acquire);
if (seq_.load(std::memory_order_relaxed) == before) return before;
}
return 0;
}
private:
std::atomic<std::uint32_t> seq_{0};
LiveValues values_{};
};
// Linear per-frame glide with EXACT termination: once the target is within one step the value
// becomes the target itself. An asymptotic smoother would leave the value forever a hair off,
// pinning the filter's exact-equality cutoff skip on the always-re-solve path; this returns to
// the skip path the moment the move completes. A non-positive step snaps (no rate known yet).
struct ValueRamp {
double value = 0.0;
double target = 0.0;
double step = 0.0;
bool moving() const { return value != target; }
void set(double v) { value = v; target = v; }
void aim(double t) { target = t; }
// Advances one frame; returns whether the value actually moved.
bool tick() {
if (value == target) return false;
const double delta = target - value;
if (step <= 0.0 || (delta <= step && delta >= -step)) value = target;
else value += (delta > 0.0) ? step : -step;
return true;
}
};
// Per-frame ramp step for a control whose full travel is 1.0, at `sampleRate`. A non-positive
// rate yields 0 — the ramp then snaps rather than inventing a rate.
double liveRampStep(double sampleRate);
} // namespace reasampler::instrument::engine
+10
View File
@@ -14,6 +14,8 @@
namespace reasampler {
namespace instrument::engine { class LiveParams; } // live_params.h; SampleData holds a pointer
using audio::AudioSample;
using instrument::engine::VelocityCurve;
@@ -166,6 +168,14 @@ struct SampleData {
PlayParams play;
// The live-parameter block a sounding voice tracks, or null for the bare latched engine
// (the default — with no block attached the core is byte-identical to the pre-live one).
// NON-OWNING and deliberately not per-snapshot: the shell owns ONE block that outlives
// every instrument snapshot, so a voice still ringing out of the drain slot follows the
// same knob as a live one. That is the desired behaviour — it is the note the user is
// hearing. Do not "fix" it by moving ownership into the snapshot.
const instrument::engine::LiveParams* live = nullptr;
// A framesR of a different length than frames is treated as absent — a malformed pair
// never half-plays.
int channelCount() const {
+54 -3
View File
@@ -92,24 +92,41 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
pitchEnv_.configure(p.pitchEnv);
pitchEnv_.noteOn();
// A restart lands every live glide back on the new note's own values, at a step derived
// from this sample's rate rather than any assumed one.
filterRamping_ = false;
const double rampStep = instrument::engine::liveRampStep(
static_cast<double>(sample.sampleRate));
rBaseCutoff_.step = rampStep;
rModAmount_.step = rampStep;
rResonance_.step = rampStep;
rMorph_.step = rampStep;
rDrive_.step = rampStep;
// Filter: reset() clears integrator state for the new note (prepare() preserves it —
// voice_filter.h / filter/CLAUDE.md). Velocity maps through the curve once here, off the
// per-frame path, exactly as the amp's velocityGain_ does.
filterOn_ = p.filter.enabled;
if (filterOn_) {
filterSettings_ = p.filter.settings;
filterCutoffNorm_ = static_cast<double>(p.filter.settings.cutoffNorm);
filterModAmount_ = p.filter.modAmount;
filterKeyTrack_ = p.filter.keyTrack;
filterVelOffset_ =
p.filter.velAmount * p.filter.velocityCurve.eval(static_cast<double>(velocity));
filterRate_ = static_cast<double>(sample.sampleRate);
rModAmount_.set(p.filter.modAmount);
rResonance_.set(static_cast<double>(p.filter.settings.resonanceNorm));
rMorph_.set(static_cast<double>(p.filter.settings.morphNorm));
rDrive_.set(static_cast<double>(p.filter.settings.driveNorm));
filterEnv_.configure(p.filter.env);
filterEnv_.noteOn();
filter_.reset();
updateFilterCutoffBase(note);
// The note's ONE full solve — Q, morph and drive are constants for its lifetime, so
// every later re-solve is the cheap cutoff-only path. A modulated voice supersedes this
// cutoff in tickFilterCutoff on its first frame, before any sample reaches the kernel.
// The note's ONE full solve — Q, morph and drive are constants for its lifetime unless
// a live move glides them, so every later re-solve is the cheap cutoff-only path. A
// modulated voice supersedes this cutoff in tickFilterCutoff on its first frame,
// before any sample reaches the kernel.
instrument::engine::filter::FilterSettings s = p.filter.settings;
s.cutoffNorm = filterBaseCutoff_;
filter_.prepare(s, filterRate_);
@@ -178,6 +195,40 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
ratio_ = baseRatio_; // seeded; advanceFrame recomputes per frame under the active engine.
}
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);
if (!filterOn_) return; // filter enable is a discrete toggle: it travels by reload
filterEnv_.applyLive(live.filterEnv);
filterCutoffNorm_ = static_cast<double>(live.filterSettings.cutoffNorm);
filterKeyTrack_ = live.filterKeyTrack;
filterSettings_.morphLaw = live.filterSettings.morphLaw;
const double baseTarget = filterCutoffBaseTarget(note_);
if (snap) {
rBaseCutoff_.set(baseTarget);
rModAmount_.set(live.filterModAmount);
rResonance_.set(static_cast<double>(live.filterSettings.resonanceNorm));
rMorph_.set(static_cast<double>(live.filterSettings.morphNorm));
rDrive_.set(static_cast<double>(live.filterSettings.driveNorm));
filterBaseCutoff_ = static_cast<float>(baseTarget);
filterModAmount_ = live.filterModAmount;
filterRamping_ = false;
prepareFilterFromRamps();
return;
}
rBaseCutoff_.aim(baseTarget);
rModAmount_.aim(live.filterModAmount);
rResonance_.aim(static_cast<double>(live.filterSettings.resonanceNorm));
rMorph_.aim(static_cast<double>(live.filterSettings.morphNorm));
rDrive_.aim(static_cast<double>(live.filterSettings.driveNorm));
filterRamping_ = rBaseCutoff_.moving() || rModAmount_.moving() || rResonance_.moving() ||
rMorph_.moving() || rDrive_.moving();
}
void Voice::retune(int note) {
// Mono legato takeover: move the pitch, touch NOTHING else — the amplitude envelope keeps
// running (no re-attack), the read head keeps its position, the shifter keeps its ring
+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.
//
+31 -6
View File
@@ -33,6 +33,32 @@ VoiceEngine::VoiceEngine(std::size_t maxVoices, const SampleData& sample,
}
}
bool VoiceEngine::refreshLive() {
const instrument::engine::LiveParams* block = sample_.live;
if (block == nullptr) return false; // bare engine: the latched note-on values stand
instrument::engine::LiveValues observed;
const std::uint32_t generation = block->read(observed);
if (generation == 0 || generation == liveGeneration_) return false;
liveGeneration_ = generation;
live_ = observed;
haveLive_ = true;
return true;
}
void VoiceEngine::applyLiveToActive() {
if (!refreshLive()) return;
for (Voice& voice : voices_) {
if (voice.active()) voice.applyLive(live_, /*snap=*/false);
}
}
void VoiceEngine::startVoice(Voice& voice, int note, int velocity) {
refreshLive();
voice.start(note, velocity, sample_, /*declickTakeover=*/takeoverDeclick_);
if (haveLive_) voice.applyLive(live_, /*snap=*/true);
voice.setStartOrder(nextStartOrder_++);
}
std::size_t VoiceEngine::activePreserveVoices() const {
// Count only voices that are SOUNDING A NOTE (playable span still running), not voices
// that have finished their note but are still ringing out a declick tail. A ramp-only
@@ -122,8 +148,7 @@ std::size_t VoiceEngine::monoNoteOn(int note, int velocity) {
// RETRIGGER takeover / first note of a phrase: (re)start the voice. The declick opt-in
// rides every mono restart; start() self-gates it on the voice being ACTIVE, so a
// first-note fresh start never ramps — only a hard cut of a sounding tone.
v.start(note, velocity, sample_, /*declickTakeover=*/takeoverDeclick_);
v.setStartOrder(nextStartOrder_++);
startVoice(v, note, velocity);
return 0;
}
@@ -150,8 +175,7 @@ void VoiceEngine::monoNoteOff(int note) {
// Retrigger fallback: re-strike the fallen-back-to note at its own original velocity.
// Peer restart site of monoNoteOn's takeover — same declick opt-in (the fallback also
// hard-cuts the sounding tone).
v.start(fb.note, fb.velocity, sample_, /*declickTakeover=*/takeoverDeclick_);
v.setStartOrder(nextStartOrder_++);
startVoice(v, fb.note, fb.velocity);
}
std::size_t VoiceEngine::noteOn(int note, int velocity) {
@@ -174,8 +198,7 @@ std::size_t VoiceEngine::noteOn(int note, int velocity) {
// active, so a free-voice start never ramps — only an at-cap steal, which is the same hard
// cut of a sounding tone as the mono retrig takeover.
const std::size_t v = allocateVoice();
voices_[v].start(note, velocity, sample_, /*declickTakeover=*/takeoverDeclick_);
voices_[v].setStartOrder(nextStartOrder_++);
startVoice(voices_[v], note, velocity);
return v;
}
@@ -225,6 +248,7 @@ void VoiceEngine::render(AudioSample* out, std::size_t frameCount) {
// The VST3 process callback hands us the host's output channel buffer here, so the
// audio thread never touches the heap.
if (out == nullptr || frameCount == 0) return;
applyLiveToActive(); // block boundary, once — never inside the frame loop
for (Voice& voice : voices_) {
if (!voice.active()) continue;
for (std::size_t f = 0; f < frameCount; ++f) {
@@ -240,6 +264,7 @@ void VoiceEngine::render(AudioSample* left, AudioSample* right, std::size_t fram
// iteration, same mid-block idle short-circuit) so stereo and mono share one stealing/idle
// discipline; only the per-frame call differs (renderFrameStereo vs renderFrame).
if (left == nullptr || right == nullptr || frameCount == 0) return;
applyLiveToActive(); // block boundary, once — never inside the frame loop
for (Voice& voice : voices_) {
if (!voice.active()) continue;
for (std::size_t f = 0; f < frameCount; ++f) {
+18
View File
@@ -10,6 +10,7 @@
#include <vector>
#include "core/audio/peaks.h"
#include "core/instrument/engine/live_params.h"
#include "core/instrument/engine/play_params.h"
#include "core/instrument/engine/voice.h"
@@ -106,6 +107,23 @@ private:
// one per the documented policy. Always returns a valid index (maxVoices >= 1).
std::size_t allocateVoice();
// --- Live-parameter observation (live_params.h) ---
// The ONE place the seqlock is read: at block start and at each note-on, on the audio
// thread, never per frame. A torn or never-published read leaves the last good snapshot
// in place rather than spinning. Returns whether a NEW generation landed.
bool refreshLive();
// Block-boundary refresh: pushes a newly-observed generation into every sounding voice,
// which glides toward it. No-op when nothing changed (and when no block is attached).
void applyLiveToActive();
// The one restart path: start the voice, hand it the live values outright (it has nothing
// to glide from), and stamp its age. Shared by the poly steal and both mono restarts so
// no restart site can miss the live handoff.
void startVoice(Voice& voice, int note, int velocity);
instrument::engine::LiveValues live_{};
std::uint32_t liveGeneration_ = 0; // last generation observed; 0 = none yet
bool haveLive_ = false;
// Count of active Preserve-engine voices (for the Preserve cap). Rescanned per note-on
// (cheap: bounded by maxVoices) rather than maintained as a running tally.
std::size_t activePreserveVoices() const;