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