Merge Θ-W3-T1: live parameter delivery to sounding voices, holding normalized stage position across time edits

This commit is contained in:
2026-07-31 06:44:04 -04:00
26 changed files with 1857 additions and 88 deletions
+47 -2
View File
@@ -154,6 +154,39 @@ pitch envelope/curve (AD?) which is off by default."*
- **S15/S16 are Tier 01 engine features, not Tier 2/3** — do not let the held Tier-2
feature list (velocity layers / round-robin / filter work) drive their build shape.
### Live parameter delivery — a knob moves the note already sounding (settled 2026-07-30)
Daniel's ruling, verbatim: *"hell no, I was going to bring that up for the other envelopes. We
must live compute, latching the parameters at note on is not acceptable. long term these will be
automatable parameters."* It rejects the precedent, not one instance of it.
- **Which controls are live is ONE decision, recorded in ONE place** — `isLiveDeckParam` and
`liveCommitFor` (`ui/deck_groups`), whose header is THE home for which controls are live and
why each exclusion is excluded — see there rather than restating the list here.
- **Ownership sits ABOVE every snapshot.** `SampleData::live` is a NON-OWNING pointer to the one
block the shell owns per instance. The member-ordering constraint that enforces it, and why,
are recorded at `liveParams_` in `shell/instrument/reasampler_processor.h`. A drain voice
tracking the knob is the DESIRED behaviour — it is the note the user is hearing.
- **Null is the bare engine.** `live == nullptr` is byte-identical to the pre-live core, which
is why `sampler_core`'s regression baselines needed no change.
- **Observation is at block boundaries, never per frame.** `VoiceEngine` reads the seqlock once
per `render()` and once per note-on; the per-sample path gained three predicted branches (the
voice's filter-ramp check and each envelope smoother's active check), all false at rest, and
no indirection.
- **A fresh note SNAPS, a sounding one holds φ.** They are different entry points on purpose
(`snapLive` vs `applyLive`): a voice that has rendered nothing has no phase to hold, and the
φ rule reads its stage-0 position under a stale zero-length stage as a completed stage. One
function serving both silently discarded every newly-dialled attack.
- **The mid-stage rule is HOLD NORMALIZED STAGE POSITION** (Daniel's pick among six candidates):
φ = elapsed/duration is held across a stage-time change, so the level is continuous by
construction and the remainder takes its share of the new duration. Stated over normalized
position rather than output level ON PURPOSE, so a per-segment curve exponent composes with
it as a pure map of φ. Recomputing from absolute elapsed (which steps) is the rejected
alternative — do not reintroduce it.
- **Two genuine level steps are smoothed, not ruled away**: a sustain level moved while the
voice holds it, and a stage duration dialled to exactly zero mid-stage. Both are absorbed by
the envelope's own bounded offset smoother.
### Non-goals / guardrails (instrument-specific; repo-wide invariants live in root CLAUDE.md)
- **No cross-platform / multi-format.** Windows-only, VST3-only, REAPER-only (D5). Do not
@@ -199,7 +232,8 @@ slider couldn't. Two pure modules split the forward (draw) and inverse (edit) ma
- The engine is the `sampler_core` CMake target over FOUR headers and TWO TUs, split on its own responsibility seam — cold note routing vs the hot per-sample render:
- `play_params.h` — the value layer: `PlayParams`/`AdsrParams`/`TriggerParams`/`PitchEnvParams`/`FilterParams`, the per-instance mode enums (`ChannelMode`/`VoiceMode`/`MonoTrigger`), and `SampleData` (the ONE loaded capture: decoded PCM + root + loop + start + keyTrack + velocity curve + play params). Shared by the engine, the codec, and the editor, so a UI/codec TU reading a param struct doesn't recompile when a `Voice` member changes. `FilterParams` stores the filter module's own `FilterSettings` by value rather than a parallel copy of its normalized positions.
- `envelopes.h` — the three per-frame evaluators (`AdsrEnvelope` AHDSR, `TriggerEnvelope` fade shape, `PitchEnvelope` AD offset), CONCRETE and fully header-inline. Never give them a common base or a virtual `tick()`: they are called per-voice-per-sample. The filter envelope is a SECOND `AdsrEnvelope` instance on the voice, not a fourth class.
- `envelopes.h` — the three per-frame evaluators (`AdsrEnvelope` AHDSR, `TriggerEnvelope` fade shape, `PitchEnvelope` AD offset), CONCRETE and fully header-inline. Never give them a common base or a virtual `tick()`: they are called per-voice-per-sample. The filter envelope is a SECOND `AdsrEnvelope` instance on the voice, not a fourth class. `AdsrEnvelope`/`PitchEnvelope` also own `applyLive` (the φ-holding mid-stage rule), its fresh-note peer `snapLive`, and `StepSmoother`, the bounded offset that absorbs the two level steps φ cannot cover.
- `live_params.h` / `live_params.cpp` — the live-parameter block: `LiveValues` (the plain, trivially-copyable bundle the audio thread observes), the single-writer `LiveParams` seqlock that publishes it without a lock or a torn read, `foldLive` (the ONE derivation from `PlayParams` — every publisher goes through it so the two representations cannot drift), and `ValueRamp`, the per-frame glide whose EXACT termination is what lets the filter's equality-compare cutoff skip re-engage. Links no engine: the block is a value the voice observes, not a thing the engine owns.
- `voice.h` / `voice.cpp` — one voice. The per-SAMPLE render half (`advanceFrame` and everything it calls) is INLINE IN THE HEADER by RT constraint; the per-NOTE half (note-on setup incl. the Preserve ring prime, legato retune, gate-off, the off-thread shifter presize) is out of line in the TU. The voice owns its own `VoiceFilter` and filter envelope, run between the pitch stage and the amp multiply — see `engine/filter/CLAUDE.md`.
- `voice_engine.h` / `voice_engine.cpp` — `VoiceEngine`: note routing, bounded-stealing allocation, user-parameterized voice count (132, default 16), `VoiceMode` Poly/Mono (last-note held-note stack, `MonoTrigger` Retrigger/Legato), two-tier panic (CC 123 = all-notes-off release, CC 120 = immediate hard-stop including Trigger one-shots), and the block render loops. Preview injects a synthetic note-on at the loaded capture's root note into the main `VoiceEngine` — no dedicated `PreviewCard`; preview obeys polyphony/mono/voice-stealing/envelopes.
- `pitch_shift` — hand-rolled **correlation-aligned SOLA** (splice-overlap-add) pitch shifter for the Preserve playback mode: one active read tap chases the write head at the shift ratio; each splice jump is refined by a cross-correlation search so the new read point is waveform-aligned, then old and new taps are crossfaded (raised-cosine, amplitude-complementary). Replaces the prior dual-tap OLA whose fixed half-window tap offset caused anti-phase cancellation on many source frequencies. **GA2:** ring buffer **primed with the actual upcoming source** at note-on (was zero-filled) → gap-free frame-0 onset, ~25 ms Preserve onset latency eliminated (Preserve now speaks on frame 0, matching Varispeed), and real-content-bounded tail (last-window tail-truncation gone). No third-party dependencies; RT-discipline: no allocation in `process()`.
@@ -227,7 +261,7 @@ slider couldn't. Two pure modules split the forward (draw) and inverse (edit) ma
- `param_slider` — parameter control-panel: vertical stack of TOGGLE (two-segment selector) and SLIDER (horizontal track) rows; maps normalized value to/from handle pixel.
- `embed_strip` — compact single-row control layout for embed mode in the track FX chain.
- `knob_deck` — pure knob-deck layout + hit-test (FB1): group-box / caption-row / compact-toggle / knob-cell geometry, deterministic whole-group wrap, `DeckLayout` / `DeckHit`. Mirror of `action_bar`/`param_slider`; no LICE or REAPER types.
- `deck_groups` — WHICH groups the Sample face's deck carries, split from `knob_deck`'s HOW they lay out: the `DeckParam` control-id space (the editor's `ParamControl` is an alias of it), the `DeckGroupId` list, `sampleDeckGroups` in signal-flow order (**pitch → filter → amp**, then voice/master), and the deck's bipolar-knob law. Reads `PlayMode` for the AMP group's Gate/Trigger face, which is why this and not `knob_deck` is the module that touches the engine's value layer.
- `deck_groups` — also home to `isLiveDeckParam` and `liveCommitFor`, the editor's whole commit-tier routing decision (see "Live parameter delivery" above); WHICH groups the Sample face's deck carries, split from `knob_deck`'s HOW they lay out: the `DeckParam` control-id space (the editor's `ParamControl` is an alias of it), the `DeckGroupId` list, `sampleDeckGroups` in signal-flow order (**pitch → filter → amp**, then voice/master), and the deck's bipolar-knob law. Reads `PlayMode` for the AMP group's Gate/Trigger face, which is why this and not `knob_deck` is the module that touches the engine's value layer.
- `curve_popup` — pure curve-popup geometry + dismissal test (FB1): centered sheet over the Sample face — width/height clamps, title row, Close button rect, curve-box rect, outside-sheet dismissal test. Mirror of `overflow_menu`; no LICE or REAPER types.
- `envelope_overlay` — pure amp-envelope→polyline geometry for the Sample-view envelope overlay (read from `envelope_overlay.h`): maps Gate's AHDSR shape or Trigger's fade-in/unity/%-length/fade-out shape to a polyline inside a rect at the shared time base (Gate: a bounded param-domain schematic, sample-length-free; Trigger: PCM-aligned wall-clock), every vertex clamped in-canvas (`x`/`y` inside the rect). Shares the `EnvNode`/`AmpEnvelope`/`timeToX`/`levelToY` vocabulary with `envelope_edit` so the drawn handle and its grab region agree pixel-for-pixel. No VST3/REAPER/LICE types at the boundary.
- `envelope_edit` — pure node hit-test + pixel-delta→clamped-param inverse map for the draggable envelope nodes (read from `envelope_edit.h`): `nodeAtPoint` resolves a grab to the nearest node within a pick radius (Chebyshev distance, draw-order tie-break); `resolveNodeDrag` maps a pixel delta since grab to a new `AmpEnvelope`, enforcing monotonic-in-time ordering between neighbouring nodes and the same caller-supplied per-param clamp bounds the sliders use — a drag can never produce a param a slider couldn't. Mirror of `card_drag`/`waveform_view`; the inverse of `envelope_overlay`'s params→polyline forward map, so node-drag and slider-edit read/write one shared model and can never diverge.
@@ -243,6 +277,17 @@ slider couldn't. Two pure modules split the forward (draw) and inverse (edit) ma
Channel-mode (D-E) bus-renegotiation design and the earlier Preserve-onset-latency
framing in the S16 guardrails. Root `CLAUDE.md` is the current source of truth
for both — do not reintroduce either superseded design.
- **A filter envelope only advances while its depth is non-zero.** `tickFilterCutoff`'s exact
skip at `modAmount == 0` skips the envelope tick along with the solve, so dialling depth up
mid-note starts the envelope from the note's stage-0 position rather than from where it would
have been. Its step smoother is frozen with it — an absorbed step sits in the offset and
emits when depth is next dialled up (bounded, and scaled by a depth ramping from 0).
Continuous either way (the contribution starts at 0), and keeping the skip is what holds the
at-rest per-sample path byte-identical — but don't read a live depth move as "resuming" an
envelope that was never running.
- **A live edit leaves the snapshot's own `sample.play` stale, on purpose.** The block, not the
snapshot, is the audio thread's source; a new voice latches the stale copy and is corrected by
`snapLive` before its first frame.
- **`keyboard_strip`'s width-uniformity guarantee is client-pixel only.** Its test sweep
covers client-pixel widths (including multiples standing in for larger client areas);
nothing in the instrument implements `IPlugViewContentScaleSupport`, so host-side DPI
+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)
+220 -64
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,25 +74,122 @@ 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 fresh voice — one that has NOT yet rendered a frame, whose
// latched copy may predate the newest edit. It takes the params outright: there is no
// phase to hold and nothing to be continuous with. applyLive cannot serve here in either
// direction — with a stale duration of 0 its phi rule reads stagePos_ == 0 as a COMPLETED
// stage and discards the newly-dialled time, and with a stale duration > 0 against a new 0
// it absorbs a full-scale step into a voice that has emitted nothing, fading the onset in.
void snapLive(const AdsrParams& params) {
params_ = params;
smooth_.clear();
}
// Live parameter delivery to a SOUNDING voice. The mid-stage rule is HOLD NORMALIZED
// STAGE POSITION: phi = elapsed/duration is kept fixed across the change, so this frame's
// level is unchanged by construction and the remainder of the stage takes its share of the
// 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).
// Once Release completes the envelope latches Finished and returns 0.0 forever (until
// the next noteOn). A single, monotonic per-frame step — the caller pulls one value per
// output frame.
//
// While the smoother runs the return may sit OUTSIDE [0,1] by the offset it is decaying
// (bounded by the step it absorbed). finished() ignores that residue, so a Release that
// completes with an offset still decaying is hard-cut when the voice frees — the audible
// remainder of a step the smoother had already taken most of.
double tick() {
const double out = tickStage();
return smooth_.active() ? out + smooth_.advance() : out;
}
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 one
// home for every segment's shape: tickStage owns only the advance and the stage
// transitions and reads its output from here, so a per-segment curve added later lands in
// one place and the smoother can never size a step against a different curve than the
// output takes.
double stageLevel(const AdsrParams& params) const {
switch (stage_) {
case Stage::Attack: {
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:
@@ -60,19 +197,13 @@ public:
return 0.0;
case Stage::Attack: {
if (params_.attackFrames <= 0) {
level_ = 1.0;
} else {
level_ = static_cast<double>(framesInStage_) /
static_cast<double>(params_.attackFrames);
if (level_ > 1.0) level_ = 1.0;
}
level_ = stageLevel(params_);
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,43 +215,38 @@ 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;
level_ = stageLevel(params_);
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;
}
case Stage::Decay: {
if (params_.decayFrames <= 0) {
level_ = params_.sustainLevel;
} else {
const double t = static_cast<double>(framesInStage_) /
static_cast<double>(params_.decayFrames);
level_ = 1.0 + (params_.sustainLevel - 1.0) * t;
}
level_ = stageLevel(params_);
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;
}
case Stage::Sustain:
level_ = params_.sustainLevel;
level_ = stageLevel(params_);
return level_;
case Stage::Release: {
@@ -129,13 +255,10 @@ public:
stage_ = Stage::Finished;
return 0.0;
}
const double t = static_cast<double>(framesInStage_) /
static_cast<double>(params_.releaseFrames);
level_ = releaseFrom_ * (1.0 - t);
if (level_ < 0.0) level_ = 0.0;
level_ = stageLevel(params_);
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 +268,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 +352,71 @@ 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(); }
// Peer of AdsrEnvelope::snapLive (see it for why the two paths cannot share code): a voice
// that has rendered nothing takes the new times and depth outright.
void snapLive(std::int64_t attackFrames, std::int64_t decayFrames, double peakSemitones) {
params_.attackFrames = attackFrames;
params_.decayFrames = decayFrames;
params_.peakSemitones = peakSemitones;
smooth_.clear();
}
// Live parameter delivery, same rule as AdsrEnvelope::applyLive: hold the normalized
// position within whichever leg the envelope is in, and absorb the depth step (peak is a
// level, not a duration). `enabled` is a discrete toggle and travels by reload, so it is
// 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
+131
View File
@@ -0,0 +1,131 @@
#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.
//
// SINGLE-WRITER IS THE CALLER'S JOB and is load-bearing: two concurrent writers can leave the
// generation EVEN mid-write (A stores gen+1, B reads odd and stores gen+2) while both copy the
// block, and a reader then accepts a torn block as coherent. Every publisher must serialize.
//
// The plain (non-atomic) block copied across the fences is the standard pragmatic seqlock:
// the fences give correct ordering, but the concurrent read of a non-atomic object is a data
// race under the C++ object model, so TSan/UBSan will report it. That report is expected, not
// a defect — there is no clean lock-free standard-C++ alternative that keeps the block a plain
// value the audio thread can copy in one shot.
//
// The writer interface deliberately assumes NO particular thread beyond single-writer, so a
// host's own parameter-change queue (delivered on the audio thread with sample offsets) can
// drive it later without a redesign.
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);
// Skip 0 on wrap (~2^31 publishes): landing there would read as "never published" and
// stall every reader until the NEXT publish — a silent mode, unlike a loud one.
const std::uint32_t next = (gen + 2 == 0u) ? 2u : gen + 2;
seq_.store(next, std::memory_order_release); // even: complete and coherent
}
// Copies the block into `out` and returns the generation actually observed, or 0 when
// 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 {
+65 -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,51 @@ 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 travels by reload instead (deck_groups.h names why).
//
// A fresh note and a sounding one take DIFFERENT envelope entry points, never one with a
// flag: a voice that has rendered nothing has no phase to hold and nothing to be
// continuous with, and the mid-stage rule misreads its stage-0 position (envelopes.h).
if (snap) {
if (playMode_ == PlayMode::Gate) env_.snapLive(live.adsr);
pitchEnv_.snapLive(live.pitchEnvAttackFrames, live.pitchEnvDecayFrames,
live.pitchEnvPeakSemitones);
} else {
if (playMode_ == PlayMode::Gate) env_.applyLive(live.adsr);
pitchEnv_.applyLive(live.pitchEnvAttackFrames, live.pitchEnvDecayFrames,
live.pitchEnvPeakSemitones);
}
if (!filterOn_) return; // filter enable is a discrete toggle: it travels by reload
if (snap) filterEnv_.snapLive(live.filterEnv);
else filterEnv_.applyLive(live.filterEnv);
filterCutoffNorm_ = static_cast<double>(live.filterSettings.cutoffNorm);
filterKeyTrack_ = live.filterKeyTrack;
filterSettings_.morphLaw = live.filterSettings.morphLaw;
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 — glides AND
// envelopes: a fresh note has nothing to glide from, and its copy may predate the edit.
//
// What is NOT here is the point: velocity and its curve result, the note number and the
// pitch ratio, and the decoded PCM stay latched at note-on.
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;
+58
View File
@@ -97,4 +97,62 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
return out;
}
bool isLiveDeckParam(DeckParam id) {
switch (id) {
case DeckParam::kAttack:
case DeckParam::kHold:
case DeckParam::kDecay:
case DeckParam::kSustain:
case DeckParam::kRelease:
case DeckParam::kPitchEnvAttack:
case DeckParam::kPitchEnvDecay:
case DeckParam::kPitchEnvDepth:
case DeckParam::kFilterMorph:
case DeckParam::kFilterCutoff:
case DeckParam::kFilterQ:
case DeckParam::kFilterDrive:
case DeckParam::kFilterModAmt:
case DeckParam::kFilterKeyTrack:
case DeckParam::kFilterEnvAttack:
case DeckParam::kFilterEnvHold:
case DeckParam::kFilterEnvDecay:
case DeckParam::kFilterEnvSustain:
case DeckParam::kFilterEnvRelease:
return true;
// Listed rather than defaulted so a newly added control is a COMPILE error here (the
// -Wswitch gate is GCC/Clang; MSVC's C4062 is off at this project's warning level)
// instead of silently defaulting to non-live. Reasons live in the header.
case DeckParam::kPlayMode:
case DeckParam::kPitchEngine:
case DeckParam::kTrigLength:
case DeckParam::kTrigFadeIn:
case DeckParam::kTrigFadeOut:
case DeckParam::kPitchEnvEnable:
case DeckParam::kKeyTrack:
case DeckParam::kFilterEnable:
case DeckParam::kFilterVel:
case DeckParam::kFilterLaw:
case DeckParam::kVoiceCount:
case DeckParam::kVoiceMode:
case DeckParam::kMonoTrigger:
case DeckParam::kMasterGain:
case DeckParam::kCount: // not a control
return false;
}
return false; // unreachable for a valid enumerator; silences a warning.
}
bool liveCommitFor(LiveDragKind kind, int paramId, PlayMode playMode) {
switch (kind) {
case LiveDragKind::kDeckKnob:
return paramId >= 0 && paramId < static_cast<int>(DeckParam::kCount) &&
isLiveDeckParam(static_cast<DeckParam>(paramId));
case LiveDragKind::kEnvNode:
return playMode == PlayMode::Gate;
case LiveDragKind::kOther:
return false;
}
return false;
}
} // namespace reasampler::instrument::ui
+34
View File
@@ -72,6 +72,40 @@ enum DeckGroupId {
// reservation (knob_deck.h) so a mode flip never reflows the neighbouring groups.
std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode);
// Whether control `id` is delivered LIVE — straight to the voices that are already sounding —
// rather than through an instrument reload. The line is drawn at continuously-valued playback
// controls, so this is a routing decision at the editor's commit site rather than a property
// of any one knob; moving a control across the line is a change here and nowhere else.
//
// THE home for why each excluded control is excluded. Five continuous controls are outside the
// live set, plus every discrete toggle:
// - the discrete toggles (play mode, pitch engine, filter enable/law, pitch-envelope enable)
// name a different sound rather than a different setting of one;
// - the three capture-anchored overrides (root, loop span, start frame) name positions in
// the decoded PCM;
// - kKeyTrack and kFilterVel feed values a voice latches at note-on by design (the pitch
// ratio and the velocity-curve result), so live delivery would retune or re-gain a note
// already struck;
// - kTrigLength resolves playEnd_, a fact about the note. kTrigFadeIn/kTrigFadeOut are pure
// amplitude shape and would be live-able in principle, but they live in `sample.play` and
// are baked into SampleData at build time — the engine rebuild copies that verbatim, so
// only a reload can deliver them without widening LiveValues. They fold into the AHD
// alongside Gate's, at which point they inherit its routing; until then they reload.
// Consequence, stated plainly: a Trigger-mode instance gets NO live delivery on its amplitude
// controls. Only the filter and pitch-envelope knobs move a sounding Trigger one-shot.
bool isLiveDeckParam(DeckParam id);
// The editor drag kinds that can commit live, in this pure module's own vocabulary (the
// shell's DragKind maps onto it) so the WHOLE routing decision — not just the predicate — is
// testable without a host.
enum class LiveDragKind { kOther, kDeckKnob, kEnvNode };
// Whether a drag of `kind` commits live. A deck knob is live per isLiveDeckParam (negative ids
// are the shell's processor-side sentinels and out-of-range ids are not controls, so neither
// reaches the enum); an envelope-node drag is live only in Gate, where it edits the AHDSR —
// in Trigger the same drag rewrites the play span, which is not a live control.
bool liveCommitFor(LiveDragKind kind, int paramId, PlayMode playMode);
// The deck's BIPOLAR knob law: 0.5 of the knob's travel is zero depth, the ends are -1 and
// +1. Exact inverses, and exact at the centre detent (0.5 -> 0 -> 0.5), so a knob parked at
// centre can never persist a hair of modulation. Out-of-range norm clamps to the endpoints.
+14
View File
@@ -67,6 +67,20 @@ scattered `#ifdef`s in the VST shell, except the one described below).
- **Verify** all identity/factory wiring against the vendored Steinberg SDK
(`DEF_CLASS2` / `INLINE_UID` / `FUID` from `pluginfactory.h` + `funknown.h`).
**The three commit tiers (Θ-W3).** An edit reaches the audio by exactly one of three routes, and
which route a control takes is decided once, by the pure `isLiveDeckParam` / `liveCommitFor` pair
(`core/instrument/ui/deck_groups`) that the editor's `dragCommitsLive` only maps onto — see
`core/instrument/CLAUDE.md`'s "Live parameter delivery" for the rule and its rationale.
1. **Full reload**`reloadInstrument`: bridge read, WAV re-decode, fresh engine, snapshot swap.
2. **Engine rebuild**`rebuildVoiceEngine`: same drain-slot swap around the already-decoded
`SampleData`. Voice count / mode / mono trigger.
3. **Live**`publishLiveParams` (and `masterGain_`, the original of the shape): a lock-free
publish the audio thread observes at block boundaries. No rebuild, no snapshot, no disk.
The editor's `commitLive` is the tier-3 peer of `commitAndReload`; why it still writes the
parameter set is recorded at its declaration in `reasampler_editor.h`, and why `liveParams_` is
declared ahead of the instrument slots at that member in `reasampler_processor.h`.
**Non-goals / guardrails.**
- The instrument never captures and never inserts into the arrange. Playback is a
read-only act over the bank. Any instrument path that captures, places a timeline
+7
View File
@@ -104,6 +104,13 @@ void ReaSamplerEditor::onMouseUp(int x, int y) {
invalidate();
return;
}
// A live control already reached the voices during the drag; its release commits the
// final value through the same tier.
if (dragCommitsLive(kind, paramId)) {
commitLive();
invalidate();
return;
}
// Drag-off delete: releasing a curve-node drag well outside the box removes the dragged
// point (deletePoint refuses the two endpoints, so an endpoint drag-off is a plain move —
// its amp keeps the last clamped drag value).
@@ -107,6 +107,9 @@ void ReaSamplerEditor::dragDeck(int x, int y) {
// grab. Live feedback; parameter-set commits land on WM_LBUTTONUP.
(void)x;
applyDeckKnob(dragParamId_, knobDragValue(dragKnobStartValue_, y - dragStartY_));
// A live control is delivered on every move, not only on release — that is the whole
// point: the note already sounding tracks the hand on the knob.
if (dragCommitsLive(DragKind::kDeckKnob, dragParamId_)) commitLive();
invalidate();
}
@@ -78,6 +78,9 @@ void ReaSamplerEditor::dragWaveform(const FaceLayout& fl, int x, int y) {
const AmpEnvelope edited = resolveNodeDrag(dragStartEnv_, envNode_, overlay, totalSeconds,
envClampBounds(), dx, y - dragStartY_);
unpackEnvelope(edited, frames, dragStartFrame_, params_.play);
// In Gate the node IS a live AHDSR control, so the sounding note follows the drag;
// Trigger's nodes rewrite the play span and still commit on release.
if (dragCommitsLive(DragKind::kEnvNode)) commitLive();
invalidate(); // live feedback; commit on WM_LBUTTONUP
return;
}
+20 -6
View File
@@ -205,8 +205,8 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
case WM_RBUTTONUP:
return 0; // claimed so the pair never reaches DefWindowProc (no context menu)
case WM_CAPTURECHANGED:
// Capture stolen mid-drag (modal dialog, alt-tab, etc.) — restore map_ to its
// pre-grab snapshot so the in-flight live-drag mutation is rolled back, then reset
// Capture stolen mid-drag (modal dialog, alt-tab, etc.) — restore params_ to its
// pre-grab snapshot so the in-flight drag mutation is rolled back, then reset
// the drag state machine so stale capture-less WM_MOUSEMOVEs don't keep editing.
// Mirror of the panel shell's WM_CAPTURECHANGED handler (panel_window.cpp).
if (self) {
@@ -219,15 +219,29 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
}
if (self->drag_ != DragKind::kNone) {
// A scrollbar drag + the processor-side deck knobs (preview velocity -2 /
// voice count / master gain) are transient (they mutate no parameter, so
// dragStartParams_ is not a rollback target) — reset drag state only.
// Every parameter-editing drag rolls its live mutation back to the snapshot.
// voice count / master gain) mutate no params_ field, so dragStartParams_
// is not a rollback target for them — reset drag state only. Every
// params_-editing drag restores the pre-grab snapshot. Voice count and
// master gain are pre-existing exceptions to that: both write straight
// through on every move (editor voiceCount_ / processor masterGain_)
// rather than through params_, so an abandoned drag leaves them at the
// abandoned value indefinitely instead of rolling back.
const bool transient = self->drag_ == DragKind::kScrollThumb ||
(self->drag_ == DragKind::kDeckKnob &&
(self->dragParamId_ == -2 ||
self->dragParamId_ == static_cast<int>(ParamControl::kVoiceCount) ||
self->dragParamId_ == static_cast<int>(ParamControl::kMasterGain)));
if (!transient) self->params_ = self->dragStartParams_;
if (!transient) {
self->params_ = self->dragStartParams_;
// A live drag already reached the voices AND the processor's own
// parameter set on every move, so restoring params_ alone would leave
// the face painting one value while the audio plays — and getState
// persists — the abandoned one. Roll back through the same tier the
// drag used.
if (self->dragCommitsLive(self->drag_, self->dragParamId_)) {
self->commitLive();
}
}
self->drag_ = DragKind::kNone;
self->dragParamId_ = -1;
self->curvePointIndex_ = -1; // curve-node drag state (peer reset)
+18
View File
@@ -136,6 +136,24 @@ void ReaSamplerEditor::commitAndReload() {
#endif
}
void ReaSamplerEditor::commitLive() {
// UI thread only. See the declaration for why this still writes the parameter set.
if (!processor_) return;
processor_->setInstrumentParams(params_);
processor_->publishLiveParams();
}
bool ReaSamplerEditor::dragCommitsLive(DragKind kind, int paramId) const {
// The decision itself is the pure liveCommitFor's; this is only the shell's drag-kind
// vocabulary mapped onto it, so the routing is pinned by deck_groups' tests rather than
// by inspection of this file.
using instrument::ui::LiveDragKind;
const LiveDragKind k = kind == DragKind::kDeckKnob ? LiveDragKind::kDeckKnob
: kind == DragKind::kEnvNode ? LiveDragKind::kEnvNode
: LiveDragKind::kOther;
return instrument::ui::liveCommitFor(k, paramId, params_.play.playMode);
}
void ReaSamplerEditor::loadSelection(const std::string& id) {
// A load REPLACES the loaded sound. The shaping parameters (play mode, envelopes, pitch
// engine, key-track, velocity curve) are NOT reset — the one set governs whatever is
+15 -1
View File
@@ -148,7 +148,21 @@ std::string ReaSamplerProcessor::reloadInstrument() {
if (pcm) {
sample = buildSampleData(resolveCapture(*sel, params), std::move(*pcm));
havePlayable = sample.playable();
if (havePlayable) resolvedId = selId; // the concrete pick that resolved
if (havePlayable) {
// Point the built snapshot at the instance's ONE live block and seed it from
// the very PlayParams the voices latch, so an untouched knob folds to the same
// frames the build resolved and a note-on with a live block sounds identical
// to one without.
sample.live = &liveParams_;
{
// reloadMutex_ (held for this whole function) nests livePublishMutex_ here;
// publishLiveParams never holds reloadMutex_, so this is the only nesting.
std::lock_guard<std::mutex> lp(livePublishMutex_);
liveParams_.publish(instrument::engine::foldLive(sample.play));
}
builtSampleRate_.store(sample.sampleRate, std::memory_order_relaxed);
resolvedId = selId; // the concrete pick that resolved
}
}
}
+11
View File
@@ -152,6 +152,17 @@ void ReaSamplerProcessor::setInstrumentParams(const InstrumentParams& params) {
params_ = params;
}
void ReaSamplerProcessor::publishLiveParams() {
const int rate = builtSampleRate_.load(std::memory_order_relaxed);
if (rate <= 0) return;
const instrument::engine::LiveValues block =
instrument::engine::foldLive(resolvePlay(instrumentParams().play, rate));
// livePublishMutex_ enforces the seqlock's single-writer contract (live_params.h) against
// reloadInstrument's publish — held for the publish call only, not the fold above.
std::lock_guard<std::mutex> lock(livePublishMutex_);
liveParams_.publish(block);
}
SampleRefs ReaSamplerProcessor::sampleRefs() {
std::lock_guard<std::mutex> lock(refsMutex_);
return sampleRefs_;
+11
View File
@@ -233,6 +233,17 @@ private:
// instrument off the audio thread. UI thread only.
void commitAndReload();
// The live peer of commitAndReload for a continuously-valued control (isLiveDeckParam):
// the same parameter-set write — so a saved project carries the edit exactly as before —
// followed by a live publish instead of a rebuild, so the note already sounding follows
// the knob. Does not repaint; callers already do. UI thread only.
void commitLive();
// Whether an in-flight drag commits live rather than through a reload. A deck knob is
// live per isLiveDeckParam; an envelope-node drag is live only in Gate, where it edits the
// AHDSR — in Trigger the same drag rewrites the play span, which is not a live control.
bool dragCommitsLive(DragKind kind, int paramId = -1) const;
// Commits `id` as the loaded capture. The one parameter set carries over — it governs
// whatever is loaded, so a load swaps the sound, not the settings.
void loadSelection(const std::string& id);
@@ -19,6 +19,7 @@
#include "shell/instrument/reaper_bridge.h"
#include "core/instrument/map/sample_map.h" // InstrumentParams (the one parameter set)
#include "core/instrument/map/component_state_io.h" // ComponentState codec
#include "core/instrument/engine/live_params.h" // LiveParams (the live-parameter block)
#include "core/instrument/engine/voice_engine.h"
namespace reasampler::vst {
@@ -151,6 +152,14 @@ public:
InstrumentParams instrumentParams();
void setInstrumentParams(const InstrumentParams& params);
// Republishes the live-parameter block from the stored parameter set, resolved against the
// rate the loaded capture was built at so an unmoved value folds to exactly the frames the
// voices already latched. THE tier-3 commit (the three tiers are listed in this
// directory's CLAUDE.md). Callers pair this with setInstrumentParams exactly as they
// paired it with reloadInstrument. No-op before anything has been decoded (the next reload
// bakes and publishes). UI thread; serialized against reloadInstrument's own publish.
void publishLiveParams();
// Per-instance channel mode (mono | stereo), guarded by channelModeMutex_, never read
// on the audio thread. Decode policy only (downmix vs L/R split) — the output bus is
// fixed stereo, so a mode change never renegotiates host I/O.
@@ -225,6 +234,28 @@ private:
ReaperBridge bridge_;
// The ONE live-parameter block for this instance, declared ahead of the instrument slots
// so it outlives every snapshot that points at it (members destruct in reverse order).
// Both live_ and draining_ observe this same block — a block owned by a snapshot would
// leave the drain's still-sounding voices deaf to the knob under them.
instrument::engine::LiveParams liveParams_;
// Serializes liveParams_.publish's two writer sites (reloadInstrument, publishLiveParams)
// only — separate from reloadMutex_ so a knob drag's publish never blocks behind a
// reload's WAV decode. The audio thread never takes this; process() only reads via
// LiveParams::read's lock-free seqlock retry.
std::mutex livePublishMutex_;
// The rate the loaded capture was decoded/built at, so a live republish resolves the
// stored wall-clock seconds to exactly the frames the built SampleData carries. 0 = nothing
// built yet.
//
// ONE BLOCK, ONE RATE: this is stamped by whichever capture built last, and a reload
// publishes the new block before installing the new instrument. Swapping to a capture at a
// different rate therefore hands drain voices still ringing from the old-rate capture
// envelope frame counts resolved at the NEW rate (~8.8% timing shift on a 48k->44.1k swap).
// Unavoidable while one block sits above every snapshot, and it touches a release tail
// only.
std::atomic<int> builtSampleRate_{0};
// --- The audio-thread handoff (drain slot) ---
// process() atomically loads live_ + draining_ at block start (two acquires, no lock).
// reloadInstrument() (off-thread, serialized by reloadMutex_) swaps a new build into
+70 -2
View File
@@ -2,8 +2,9 @@
// framework. knob_deck's own tests pin how a descriptor list LAYS OUT; these pin WHICH
// descriptors the Sample face carries: the signal-flow group order (pitch -> filter -> amp),
// the Filter group's contents, the wrapped deck height at the editor's floor width and its fit
// inside the floor window, the hit-test reaching the new filter controls, and the bipolar knob
// law's inverse pair.
// inside the floor window, the hit-test reaching the new filter controls, the bipolar knob
// law's inverse pair, and the commit-tier routing — which controls are live, and which drags
// take the live tier.
#include "../src/core/instrument/ui/deck_groups.h"
#include "../src/core/instrument/ui/sample_bands.h"
@@ -186,7 +187,74 @@ static void testBipolarKnobLawRoundTripsAndIsExactAtCentre() {
CHECK(deckNormFromBipolar(3.0) == 1.0);
}
static void testEveryDeckControlIsClassifiedLiveOrReloading() {
// The live set: the six filter tone/modulation knobs, plus every stage time and stage
// level on all three envelopes.
const DeckParam live[] = {
DeckParam::kFilterMorph, DeckParam::kFilterCutoff, DeckParam::kFilterQ,
DeckParam::kFilterDrive, DeckParam::kFilterModAmt, DeckParam::kFilterKeyTrack,
DeckParam::kAttack, DeckParam::kHold, DeckParam::kDecay, DeckParam::kSustain,
DeckParam::kRelease,
DeckParam::kFilterEnvAttack, DeckParam::kFilterEnvHold, DeckParam::kFilterEnvDecay,
DeckParam::kFilterEnvSustain, DeckParam::kFilterEnvRelease,
DeckParam::kPitchEnvAttack, DeckParam::kPitchEnvDecay, DeckParam::kPitchEnvDepth,
};
for (DeckParam p : live) CHECK(isLiveDeckParam(p));
// Everything else reloads or rebuilds; deck_groups.h is the home for why each exclusion
// is excluded.
const DeckParam reloads[] = {
DeckParam::kPlayMode, DeckParam::kPitchEngine, DeckParam::kPitchEnvEnable,
DeckParam::kFilterEnable, DeckParam::kFilterLaw, DeckParam::kFilterVel,
DeckParam::kKeyTrack, DeckParam::kTrigLength, DeckParam::kTrigFadeIn,
DeckParam::kTrigFadeOut, DeckParam::kVoiceCount, DeckParam::kVoiceMode,
DeckParam::kMonoTrigger, DeckParam::kMasterGain,
};
for (DeckParam p : reloads) CHECK(!isLiveDeckParam(p));
// COVERAGE, not cardinality: every id appears in EXACTLY ONE of the two lists. A sum check
// would stay green if an edit duplicated one id and dropped another, leaving that one
// unclassified.
for (int i = 0; i < static_cast<int>(DeckParam::kCount); ++i) {
const DeckParam p = static_cast<DeckParam>(i);
int seen = 0;
for (DeckParam q : live) if (q == p) ++seen;
for (DeckParam q : reloads) if (q == p) ++seen;
if (seen != 1) std::printf(" (deck id %d classified %d times)\n", i, seen);
CHECK(seen == 1);
}
}
static void testOnlyALiveControlsDragTakesTheLiveTier() {
// isLiveDeckParam alone is not what a user experiences — liveCommitFor is, at the editor's
// commit site. Inverting it has to FAIL a test rather than merely read wrong.
CHECK(liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(DeckParam::kFilterCutoff),
PlayMode::Gate));
// A knob's routing is the knob's, not the play mode's.
CHECK(liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(DeckParam::kAttack),
PlayMode::Trigger));
CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(DeckParam::kTrigFadeIn),
PlayMode::Trigger));
CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(DeckParam::kMasterGain),
PlayMode::Gate));
// The shell's processor-side sentinels (preview velocity is -2) and any out-of-range id
// are not parameter-set controls, so they must never reach the enum.
CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, -2, PlayMode::Gate));
CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, -1, PlayMode::Gate));
CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(DeckParam::kCount),
PlayMode::Gate));
// An envelope-node drag edits the AHDSR in Gate; the same drag in Trigger rewrites the
// play span, which is not a live control.
CHECK(liveCommitFor(LiveDragKind::kEnvNode, -1, PlayMode::Gate));
CHECK(!liveCommitFor(LiveDragKind::kEnvNode, -1, PlayMode::Trigger));
// Every other drag (markers, scrollbar, curve nodes) commits through a reload.
CHECK(!liveCommitFor(LiveDragKind::kOther, static_cast<int>(DeckParam::kFilterCutoff),
PlayMode::Gate));
}
int main() {
testEveryDeckControlIsClassifiedLiveOrReloading();
testOnlyALiveControlsDragTakesTheLiveTier();
testDeckReadsPitchThenFilterThenAmpLeftToRight();
testFilterGroupCarriesItsFiveToneControlsPlusModulation();
testAmpGroupWidthSurvivesAGateTriggerFlip();
+758
View File
@@ -0,0 +1,758 @@
// Standalone tests for LIVE PARAMETER DELIVERY into a sounding voice — no VST3, no REAPER, no
// framework. The block's own publication contract is live_params_tests; this file asserts what
// reaches the audio: the mid-stage rule holds normalized position, a level move glides, a
// fresh note takes the newest block outright, every stage time and stage level on all three
// envelopes moves the note already sounding, a filter knob does too, two snapshots sharing one
// block behave identically (the drain slot), what stays latched at note-on stays latched, and
// an unmoved block renders byte-identically to the engine with no block at all.
#include "../src/core/instrument/engine/voice_engine.h"
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <vector>
using namespace reasampler;
using instrument::engine::LiveParams;
using instrument::engine::LiveValues;
using instrument::engine::foldLive;
namespace flt = reasampler::instrument::engine::filter;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
constexpr double kPi = 3.14159265358979323846;
constexpr int kRate = 48000;
static SampleData periodicSine(std::size_t frames, double period) {
SampleData s;
s.frames.resize(frames);
for (std::size_t i = 0; i < frames; ++i) {
s.frames[i] = static_cast<float>(std::sin(2.0 * kPi * static_cast<double>(i) / period));
}
s.sampleRate = kRate;
s.rootNote = 60;
// Amp held wide open so a rendered frame is the (filtered) source, undisturbed by the
// envelope under test elsewhere in this file.
s.play.adsr.sustainLevel = 1.0;
return s;
}
static double maxAbsDelta(const std::vector<AudioSample>& v, std::size_t from, std::size_t to) {
double worst = 0.0;
for (std::size_t i = from + 1; i < to && i < v.size(); ++i) {
const double d = std::fabs(static_cast<double>(v[i]) - static_cast<double>(v[i - 1]));
if (d > worst) worst = d;
}
return worst;
}
static double peakOf(const std::vector<AudioSample>& v, std::size_t from, std::size_t to) {
double peak = 0.0;
for (std::size_t i = from; i < to && i < v.size(); ++i) {
peak = (std::max)(peak, std::fabs(static_cast<double>(v[i])));
}
return peak;
}
static SampleData filteredSine() {
SampleData s = periodicSine(200000, 64.0);
s.play.filter.enabled = true;
s.play.filter.settings.cutoffNorm = 0.8f;
s.play.filter.settings.resonanceNorm = 0.9f;
s.play.filter.settings.morphNorm = 1.0f;
return s;
}
// A low corner with real envelope depth, so the filter ENVELOPE's shape is what the timbre
// depends on rather than the static knob position.
static void filterSweep(SampleData& s) {
s.play.filter.enabled = true;
s.play.filter.settings.cutoffNorm = 0.15f;
s.play.filter.settings.resonanceNorm = 0.6f;
s.play.filter.settings.morphNorm = 1.0f;
s.play.filter.modAmount = 0.8;
}
// Renders `blocks` blocks of `blockFrames` through a one-voice engine over `sample`,
// republishing `changed` at the top of block `changeAfter` and gating the note off at the top
// of `noteOffBlock` (-1 holds it). Voice-major render order is the engine's, so a fixed block
// size is what makes two runs comparable.
struct Run {
std::vector<AudioSample> out;
};
// The note is an octave above the root on purpose: key-tracking scales (note - root), so a
// root-note test would leave the key-track control with nothing to move.
constexpr int kTestNote = 72;
static Run renderWithLive(SampleData& sample, LiveParams* block, int blockFrames, int blocks,
int changeAfter, const LiveValues* changed, int noteOffBlock = -1,
int velocity = 100) {
sample.live = block;
if (block) block->publish(foldLive(sample.play));
VoiceEngine engine(1, sample);
engine.noteOn(kTestNote, velocity);
Run r;
for (int b = 0; b < blocks; ++b) {
if (block && changed && b == changeAfter) block->publish(*changed);
if (b == noteOffBlock) engine.noteOff(kTestNote);
engine.render(r.out, static_cast<std::size_t>(blockFrames));
}
return r;
}
// --- The mid-stage rule (candidate iv): hold normalized stage position ------------------
static void testStageDurationChangeHoldsPhase() {
AdsrParams p;
p.attackFrames = 1000;
p.sustainLevel = 1.0;
AdsrEnvelope unedited, edited;
unedited.configure(p);
edited.configure(p);
unedited.noteOn();
edited.noteOn();
for (int i = 0; i < 500; ++i) { unedited.tick(); edited.tick(); }
AdsrParams longer = p;
longer.attackFrames = 2000; // doubled while the voice sits halfway up the attack
edited.applyLive(longer);
// Continuity: the very next frame is UNCHANGED by the edit. Exact, not approximate —
// phi is held, and the level is a pure function of phi.
const double a = unedited.tick();
const double b = edited.tick();
CHECK(a == b);
CHECK(std::fabs(b - 0.5) < 1e-12); // and it is genuinely mid-attack, not a degenerate 0/1
// The remainder takes its share of the NEW duration: half of 2000 frames left to run.
for (int i = 0; i < 998; ++i) edited.tick();
CHECK(edited.stage() == AdsrEnvelope::Stage::Attack);
edited.tick();
CHECK(edited.stage() != AdsrEnvelope::Stage::Attack);
}
static void testShortenedStageStillLandsContinuously() {
AdsrParams p;
p.attackFrames = 1000;
p.sustainLevel = 1.0;
AdsrEnvelope env;
env.configure(p);
env.noteOn();
double last = 0.0;
for (int i = 0; i < 800; ++i) last = env.tick();
AdsrParams shorter = p;
shorter.attackFrames = 100; // now SHORTER than the frames already elapsed
env.applyLive(shorter);
const double next = env.tick();
// Recomputing from absolute elapsed (800/100) would clamp to 1.0 — a step from ~0.8. The
// phi rule keeps the level where it was and finishes the remaining 20% over 20 frames.
CHECK(std::fabs(next - last) < 2e-3);
for (int i = 0; i < 19; ++i) env.tick();
CHECK(env.stage() != AdsrEnvelope::Stage::Attack);
}
static void testSustainLevelChangeGlides() {
AdsrParams p;
p.sustainLevel = 1.0;
p.releaseFrames = 100000;
AdsrEnvelope env;
env.configure(p);
env.noteOn();
for (int i = 0; i < 50; ++i) env.tick();
CHECK(env.stage() == AdsrEnvelope::Stage::Sustain);
AdsrParams quieter = p;
quieter.sustainLevel = 0.2;
env.applyLive(quieter);
double prev = 1.0;
double worstStep = 0.0;
double v = 0.0;
for (int i = 0; i < 600; ++i) {
v = env.tick();
// The first frame reproduces the pre-change level. Bounded rather than compared
// exactly: 0.2 + fl(1.0 - 0.2) does round to exactly 1.0 for THESE operands, but the
// property under test is continuity, not a bit-exactness the smoother never promised.
if (i == 0) CHECK(std::fabs(v - 1.0) < 1e-15);
const double step = std::fabs(v - prev);
if (step > worstStep) worstStep = step;
prev = v;
}
// A raw parameter swap would step 0.8 in one frame; the glide's largest single step is a
// small fraction of that, and it terminates exactly on the new level.
CHECK(worstStep < 0.05);
CHECK(v == 0.2);
}
static void testPitchEnvelopeHoldsPhaseAndGlidesDepth() {
PitchEnvParams p;
p.enabled = true;
p.attackFrames = 0;
p.decayFrames = 1000;
p.peakSemitones = 12.0;
PitchEnvelope a, b;
a.configure(p);
b.configure(p);
a.noteOn();
b.noteOn();
for (int i = 0; i < 400; ++i) { a.tick(); b.tick(); }
b.applyLive(0, 2000, 12.0); // decay doubled mid-decay
CHECK(a.tick() == b.tick()); // phi held: the semitone offset is unchanged this frame
// A depth move is a level step, so it glides rather than jumping: the first frame after
// the edit is exactly what the unedited peer emits.
PitchEnvelope c, d;
c.configure(p);
d.configure(p);
c.noteOn();
d.noteOn();
for (int i = 0; i < 400; ++i) { c.tick(); d.tick(); }
c.applyLive(0, 1000, 0.0); // depth to zero mid-decay
CHECK(c.tick() == d.tick());
// ...and it does eventually reach the new depth rather than staying put.
for (int i = 0; i < 400; ++i) c.tick();
CHECK(c.tick() == 0.0);
}
// --- The fresh-note path: snap, never the phi rule ---------------------------------------
static void testAFreshEnvelopeTakesANewlyDialledStageTimeOutright() {
// Regression, both directions. The snap path once ran applyLive's phi rule, which reads a
// stale duration of 0 as "this stage is already complete" and threw the newly-dialled
// attack away for every note until the next reload.
AdsrParams stale; // the AdsrParams default: every stage zero
stale.sustainLevel = 1.0;
AdsrEnvelope env;
env.configure(stale);
env.noteOn();
AdsrParams dialled = stale;
dialled.attackFrames = 100;
env.snapLive(dialled);
CHECK(env.tick() == 0.0); // frame 0 of a 100-frame attack, not an instant 1.0
for (int i = 0; i < 49; ++i) env.tick();
CHECK(std::fabs(env.tick() - 0.5) < 1e-12);
// Reverse: a stale non-zero attack against a newly-dialled ZERO one must not absorb a
// full-scale step into a voice that has emitted nothing — that fades in a note the user
// asked to be instant.
AdsrParams staleLong;
staleLong.attackFrames = 1000;
staleLong.sustainLevel = 1.0;
AdsrEnvelope instant;
instant.configure(staleLong);
instant.noteOn();
AdsrParams zeroAttack = staleLong;
zeroAttack.attackFrames = 0;
instant.snapLive(zeroAttack);
CHECK(instant.tick() == 1.0);
}
static void testAFreshPitchEnvelopeTakesTheNewTimesOutright() {
PitchEnvParams stale; // enabled, but every leg zero
stale.enabled = true;
PitchEnvelope env;
env.configure(stale);
env.noteOn();
env.snapLive(0, 1000, 12.0);
CHECK(env.tick() == 12.0); // at the top of the new decay leg, not past the envelope
for (int i = 0; i < 499; ++i) env.tick();
CHECK(std::fabs(env.tick() - 6.0) < 1e-12);
}
static void testANoteStartedAfterAPublishSoundsThePublishedEnvelope() {
// End-to-end shape of the snap path: a live commit deliberately leaves the snapshot's own
// sample.play stale, so the ONLY thing standing between a new note and a stale envelope is
// the snap. This is the coverage whose absence let the phi-on-snap bug through.
SampleData s = periodicSine(200000, 64.0); // adsr default: attack 0, sustain 1.0
LiveParams block;
s.live = &block;
LiveValues dialled = foldLive(s.play);
dialled.adsr.attackFrames = 24000; // half a second of attack, dialled before the note
block.publish(dialled);
VoiceEngine engine(1, s);
engine.noteOn(kTestNote, 100);
std::vector<AudioSample> out;
engine.render(out, 512);
// Control: the same stale snapshot with no block at all speaks at full level immediately.
SampleData bare = periodicSine(200000, 64.0);
VoiceEngine bareEngine(1, bare);
bareEngine.noteOn(kTestNote, 100);
std::vector<AudioSample> bareOut;
bareEngine.render(bareOut, 512);
const double barePeak = peakOf(bareOut, 0, bareOut.size());
const double peak = peakOf(out, 0, out.size());
CHECK(barePeak > 0.9);
CHECK(peak < barePeak * 0.1); // 512 frames into a 24000-frame attack: ~2% of full scale
// Reverse: a stale LONG attack against a published zero one. The note must speak at full
// level within its first cycle rather than fading in over the smoother's decay.
SampleData slow = periodicSine(200000, 64.0);
slow.play.adsr.attackFrames = 24000;
LiveParams block2;
slow.live = &block2;
LiveValues snappy = foldLive(slow.play);
snappy.adsr.attackFrames = 0;
block2.publish(snappy);
VoiceEngine fast(1, slow);
fast.noteOn(kTestNote, 100);
std::vector<AudioSample> fastOut;
fast.render(fastOut, 512);
// Source period 64 read at ratio 2 peaks at output frame 8; a spurious smoother fade-in
// would still be at ~0.34 there.
CHECK(peakOf(fastOut, 0, 32) > 0.9);
}
// --- Every envelope stage, end to end through the engine ---------------------------------
// Renders the same note twice — once untouched, once with `mutate` published mid-note — and
// asserts the field reached the SOUNDING voice (the tail diverges) and only after its publish.
static void assertLiveFieldMovesTheSoundingNote(const char* name, void (*rig)(SampleData&),
void (*mutate)(LiveValues&), int noteOffBlock) {
SampleData still = periodicSine(200000, 64.0);
SampleData moved = periodicSine(200000, 64.0);
rig(still);
rig(moved);
LiveParams blockA, blockB;
LiveValues target = foldLive(moved.play);
mutate(target);
const Run baseline = renderWithLive(still, &blockA, 512, 24, -1, nullptr, noteOffBlock);
const Run edited = renderWithLive(moved, &blockB, 512, 24, 8, &target, noteOffBlock);
CHECK(baseline.out.size() == edited.out.size());
double tailDiff = 0.0;
for (std::size_t i = 512 * 9; i < baseline.out.size() && i < edited.out.size(); ++i) {
tailDiff += std::fabs(static_cast<double>(edited.out[i]) -
static_cast<double>(baseline.out[i]));
}
if (!(tailDiff > 1.0)) std::printf(" (never reached the voice: %s)\n", name);
CHECK(tailDiff > 1.0);
bool preChangeIdentical = true;
for (std::size_t i = 0; i < 512 * 8 && i < baseline.out.size(); ++i) {
if (edited.out[i] != baseline.out[i]) { preChangeIdentical = false; break; }
}
if (!preChangeIdentical) std::printf(" (moved before its publish: %s)\n", name);
CHECK(preChangeIdentical);
}
static void testEveryEnvelopeStageTimeAndLevelMovesTheSoundingNote() {
// Each rig puts the voice INSIDE the stage under test at the publish (block 8, output
// frame 4096) — a stage already passed cannot move, which is the physics, not a gap.
struct Case {
const char* name;
void (*rig)(SampleData&);
void (*mutate)(LiveValues&);
int noteOffBlock;
};
const Case cases[] = {
{"amp attack",
[](SampleData& s) { s.play.adsr.attackFrames = 48000; },
[](LiveValues& v) { v.adsr.attackFrames = 4000; }, -1},
{"amp hold",
[](SampleData& s) {
s.play.adsr.holdFrames = 48000;
s.play.adsr.decayFrames = 4000;
s.play.adsr.sustainLevel = 0.1;
},
[](LiveValues& v) { v.adsr.holdFrames = 5000; }, -1},
{"amp decay",
[](SampleData& s) {
s.play.adsr.decayFrames = 48000;
s.play.adsr.sustainLevel = 0.0;
},
[](LiveValues& v) { v.adsr.decayFrames = 8000; }, -1},
{"amp sustain",
[](SampleData& s) { s.play.adsr.sustainLevel = 1.0; },
[](LiveValues& v) { v.adsr.sustainLevel = 0.2; }, -1},
{"amp release",
[](SampleData& s) { s.play.adsr.releaseFrames = 48000; },
[](LiveValues& v) { v.adsr.releaseFrames = 6000; }, 2},
// The filter envelope: swept over a low corner with real depth, so its shape is the
// only thing the timbre depends on. The amp release is long so a gated-off voice
// keeps sounding while the filter release is measured.
{"filter env attack",
[](SampleData& s) { filterSweep(s); s.play.filter.env.attackFrames = 48000; },
[](LiveValues& v) { v.filterEnv.attackFrames = 4000; }, -1},
{"filter env hold",
[](SampleData& s) {
filterSweep(s);
s.play.filter.env.holdFrames = 48000;
s.play.filter.env.decayFrames = 4000;
s.play.filter.env.sustainLevel = 0.0;
},
[](LiveValues& v) { v.filterEnv.holdFrames = 5000; }, -1},
{"filter env decay",
[](SampleData& s) {
filterSweep(s);
s.play.filter.env.decayFrames = 48000;
s.play.filter.env.sustainLevel = 0.0;
},
[](LiveValues& v) { v.filterEnv.decayFrames = 8000; }, -1},
{"filter env sustain",
[](SampleData& s) { filterSweep(s); },
[](LiveValues& v) { v.filterEnv.sustainLevel = 0.0; }, -1},
{"filter env release",
[](SampleData& s) {
filterSweep(s);
s.play.filter.env.releaseFrames = 48000;
s.play.adsr.releaseFrames = 480000;
},
[](LiveValues& v) { v.filterEnv.releaseFrames = 6000; }, 2},
{"pitch env attack",
[](SampleData& s) {
s.play.pitchEnv.enabled = true;
s.play.pitchEnv.attackFrames = 48000;
s.play.pitchEnv.decayFrames = 48000;
s.play.pitchEnv.peakSemitones = 12.0;
},
[](LiveValues& v) { v.pitchEnvAttackFrames = 4000; }, -1},
{"pitch env decay",
[](SampleData& s) {
s.play.pitchEnv.enabled = true;
s.play.pitchEnv.decayFrames = 48000;
s.play.pitchEnv.peakSemitones = 12.0;
},
[](LiveValues& v) { v.pitchEnvDecayFrames = 8000; }, -1},
{"pitch env depth",
[](SampleData& s) {
s.play.pitchEnv.enabled = true;
s.play.pitchEnv.decayFrames = 480000;
s.play.pitchEnv.peakSemitones = 12.0;
},
[](LiveValues& v) { v.pitchEnvPeakSemitones = 0.0; }, -1},
};
for (const Case& c : cases) {
assertLiveFieldMovesTheSoundingNote(c.name, c.rig, c.mutate, c.noteOffBlock);
}
}
// --- The filter DSP's glide property, finally exercised ---------------------------------
static void testCutoffMoveAcrossPrepareDoesNotStep() {
flt::FilterSettings s;
s.cutoffNorm = 0.8f;
s.resonanceNorm = 1.0f; // maximum Q: the worst case for a coefficient step
s.morphNorm = 1.0f;
flt::VoiceFilter glide, cut;
glide.prepare(s, kRate);
cut.prepare(s, kRate);
std::vector<AudioSample> a, b;
const int boundary = 2000;
for (int i = 0; i < 4000; ++i) {
if (i == boundary) {
flt::FilterSettings moved = s;
moved.cutoffNorm = 0.3f;
glide.prepare(moved, kRate); // state PRESERVED — the documented glide property
cut.prepare(moved, kRate);
cut.reset(); // the control: state cleared, as at note-on
}
const float x = static_cast<float>(std::sin(2.0 * kPi * static_cast<double>(i) / 48.0));
a.push_back(glide.process(0, x));
b.push_back(cut.process(0, x));
}
const double localMax = maxAbsDelta(a, boundary - 400, boundary - 1);
const double glideStep = std::fabs(static_cast<double>(a[boundary]) -
static_cast<double>(a[boundary - 1]));
const double cutStep = std::fabs(static_cast<double>(b[boundary]) -
static_cast<double>(b[boundary - 1]));
// Preserving state keeps the boundary frame inside the signal's own frame-to-frame range;
// clearing it does not — which is what proves this assertion discriminates rather than
// passing on any pair of numbers.
CHECK(glideStep <= localMax);
CHECK(cutStep > glideStep * 4.0);
}
// --- Delivery into a sounding voice ------------------------------------------------------
static void testUnmovedBlockIsByteIdenticalToNoBlockAtAll() {
SampleData bare = filteredSine();
SampleData blocked = filteredSine();
LiveParams block;
const Run without = renderWithLive(bare, nullptr, 512, 20, -1, nullptr);
const Run with = renderWithLive(blocked, &block, 512, 20, -1, nullptr);
CHECK(without.out.size() == with.out.size());
bool identical = true;
for (std::size_t i = 0; i < without.out.size() && i < with.out.size(); ++i) {
if (without.out[i] != with.out[i]) { identical = false; break; }
}
// Also the migration bar: a blob saved before this change folds to exactly the values the
// build already resolved, so reopening it sounds identical rather than merely close.
CHECK(identical);
}
static void testEveryLiveFilterControlMovesTheSoundingNote() {
struct Case { const char* name; void (*mutate)(LiveValues&); };
const Case cases[] = {
{"cutoff", [](LiveValues& v) { v.filterSettings.cutoffNorm = 0.15f; }},
{"Q", [](LiveValues& v) { v.filterSettings.resonanceNorm = 0.1f; }},
{"morph", [](LiveValues& v) { v.filterSettings.morphNorm = 0.0f; }},
{"drive", [](LiveValues& v) { v.filterSettings.driveNorm = 1.0f; }},
{"mod", [](LiveValues& v) { v.filterModAmount = 1.0; }},
{"keytrack", [](LiveValues& v) { v.filterKeyTrack = 2.0; }},
};
for (const Case& c : cases) {
SampleData still = filteredSine();
SampleData moved = filteredSine();
LiveParams blockA, blockB;
LiveValues target = foldLive(moved.play);
c.mutate(target);
const Run baseline = renderWithLive(still, &blockA, 512, 24, -1, nullptr);
const Run swept = renderWithLive(moved, &blockB, 512, 24, 8, &target);
// It moved THIS note: the tail after the publish differs audibly from the untouched
// render of the same note.
double tailDiff = 0.0;
for (std::size_t i = 512 * 12; i < baseline.out.size(); ++i) {
tailDiff += std::fabs(static_cast<double>(swept.out[i]) -
static_cast<double>(baseline.out[i]));
}
if (!(tailDiff > 1.0)) std::printf(" (control: %s)\n", c.name);
CHECK(tailDiff > 1.0);
// Nothing before the publish moved (the block is observed at block boundaries only).
bool preChangeIdentical = true;
for (std::size_t i = 0; i < 512 * 8; ++i) {
if (swept.out[i] != baseline.out[i]) { preChangeIdentical = false; break; }
}
CHECK(preChangeIdentical);
// And it ARRIVED as a glide, not as a step. Measured as how far the swept render has
// departed from the untouched one in the first frames after the publish, against how
// far it departs once settled: a glide has barely begun to diverge, a snapped delivery
// is already all the way there.
//
// This is the assertion that discriminates. A single-frame-spike metric does NOT: the
// TPT filter preserves state across prepare(), so even an instantaneous coefficient
// jump produces no isolated output spike — measured, by defeating the ramp and
// re-running, the spike statistic was unchanged while these two numbers converged.
//
// The window is a FRACTION OF THE GLIDE, not a frame count: kLiveRampSeconds is the
// full travel time, so at 1/240 of it a working glide has barely started when the
// window closes. kGlideMargin then puts the bound at the geometric middle of the two
// MEASURED populations — with the ramp in place these six controls ratio 0.0005..0.060;
// with it defeated (every live move delivered as a snap, run) they ratio 0.52..1.10.
// The bound lands at 0.175: ~3x above the worst glide, ~3x below the tamest snap.
const std::size_t rampFrames =
static_cast<std::size_t>(instrument::engine::kLiveRampSeconds * kRate);
const std::size_t window = rampFrames / 240;
const double kGlideMargin = 42.0;
const double bound = kGlideMargin * static_cast<double>(window) /
static_cast<double>(rampFrames);
double immediate = 0.0;
for (std::size_t i = 512 * 8; i < 512 * 8 + window; ++i) {
immediate = (std::max)(immediate, std::fabs(static_cast<double>(swept.out[i]) -
static_cast<double>(baseline.out[i])));
}
double settled = 0.0;
for (std::size_t i = 512 * 14; i < baseline.out.size(); ++i) {
settled = (std::max)(settled, std::fabs(static_cast<double>(swept.out[i]) -
static_cast<double>(baseline.out[i])));
}
if (!(immediate <= settled * bound))
std::printf(" (glide: %s ratio %f vs bound %f)\n", c.name,
settled > 0.0 ? immediate / settled : -1.0, bound);
CHECK(immediate <= settled * bound);
}
}
static void testOneBlockServesTwoIndependentObservers() {
// Two snapshots, one block — exactly the processor's live_/draining_ shape. The claim is
// narrow and specific: read() does NOT consume the generation, so the second engine to
// observe a publish sees it as fully as the first. Two identically-built engines are
// otherwise identical by construction, so that is the only thing the comparison pins.
SampleData liveSnapshot = filteredSine();
SampleData drainSnapshot = filteredSine();
LiveParams block;
liveSnapshot.live = &block;
drainSnapshot.live = &block;
block.publish(foldLive(liveSnapshot.play));
VoiceEngine liveEngine(1, liveSnapshot);
VoiceEngine drainEngine(1, drainSnapshot);
liveEngine.noteOn(60, 100);
drainEngine.noteOn(60, 100);
std::vector<AudioSample> a, b;
LiveValues moved = foldLive(liveSnapshot.play);
moved.filterSettings.cutoffNorm = 0.2f;
for (int blk = 0; blk < 24; ++blk) {
if (blk == 8) block.publish(moved);
liveEngine.render(a, 512);
drainEngine.render(b, 512);
}
CHECK(a.size() == b.size());
bool same = true;
for (std::size_t i = 0; i < a.size() && i < b.size(); ++i) {
if (a[i] != b[i]) { same = false; break; }
}
CHECK(same);
// The shared block genuinely moved the sound, so "identical" is a claim about both
// observers having seen it rather than about nothing having happened.
double moveEnergy = 0.0;
for (std::size_t i = 512 * 12; i < a.size(); ++i) moveEnergy += std::fabs(a[i]);
CHECK(moveEnergy > 1.0);
// And a THIRD observer, after both engines have read it, still sees the same publish.
LiveValues seen;
CHECK(block.read(seen) != 0);
CHECK(seen.filterSettings.cutoffNorm == 0.2f);
}
// --- What stays latched at note-on -------------------------------------------------------
static void testPitchRatioAndVelocityGainStayLatched() {
// A ramp source read under Varispeed: every output frame is (source at readPos) * velocity
// gain, so a moved pitch ratio or a moved velocity gain would show up directly.
//
// The filter and the pitch envelope are OFF here on purpose — that is what makes the read
// rate provable arithmetic. It also means the block's filter and pitch-envelope fields
// cannot land on this voice; that they DO land on a voice that has them enabled, and still
// leave the velocity gain alone, is the next test's job.
SampleData s;
s.frames.resize(100000);
for (std::size_t i = 0; i < s.frames.size(); ++i) {
s.frames[i] = static_cast<float>(static_cast<double>(i) / 100000.0);
}
s.sampleRate = kRate;
s.rootNote = 60;
s.velocityCurve = VelocityCurve::linear();
s.play.adsr.sustainLevel = 1.0;
LiveParams block;
s.live = &block;
block.publish(foldLive(s.play));
VoiceEngine engine(1, s);
engine.noteOn(72, 64); // an octave up: ratio 2.0
std::vector<AudioSample> out;
LiveValues hostile = foldLive(s.play);
// Everything the block CAN carry, moved as far as it goes. None of it names velocity, the
// note, the pitch ratio, or the PCM — that is the property under test.
hostile.filterKeyTrack = 2.0;
hostile.filterSettings.cutoffNorm = 0.0f;
hostile.filterModAmount = 1.0;
hostile.pitchEnvAttackFrames = 4800;
hostile.pitchEnvDecayFrames = 4800;
hostile.pitchEnvPeakSemitones = 24.0;
hostile.adsr.attackFrames = 96000; // a timed stage the voice is already past
for (int blk = 0; blk < 8; ++blk) {
if (blk == 2) block.publish(hostile);
engine.render(out, 512);
}
const double velocityGain = s.velocityCurve.eval(64.0);
bool pitchAndGainHeld = true;
for (std::size_t i = 0; i < out.size(); ++i) {
const double expected =
(static_cast<double>(2 * i) / 100000.0) * velocityGain; // ratio 2.0, latched gain
if (std::fabs(static_cast<double>(out[i]) - expected) > 1e-6) {
pitchAndGainHeld = false;
break;
}
}
CHECK(pitchAndGainHeld);
// Positive control on the same rig: a field that IS live does change the output, so the
// assertion above is not simply proving the block was ignored wholesale.
SampleData s2 = s;
LiveParams block2;
s2.live = &block2;
block2.publish(foldLive(s2.play));
VoiceEngine engine2(1, s2);
engine2.noteOn(72, 64);
std::vector<AudioSample> out2;
LiveValues quieter = foldLive(s2.play);
quieter.adsr.sustainLevel = 0.25;
for (int blk = 0; blk < 8; ++blk) {
if (blk == 2) block2.publish(quieter);
engine2.render(out2, 512);
}
CHECK(std::fabs(static_cast<double>(out2.back()) - static_cast<double>(out.back())) > 1e-4);
}
static void testVelocityGainSurvivesAHostilePublishThatReallyLands() {
// Filter AND pitch envelope enabled, so every field the block carries actually reaches the
// voice. velAmount is 0, so velocity enters the render exactly once — as the amp gain
// latched at note-on — which makes two runs at different velocities exactly proportional
// unless the publish moved that gain (a re-derived gain would have to preserve the ratio
// 100:64 to slip through).
SampleData rig = periodicSine(200000, 64.0);
rig.velocityCurve = VelocityCurve::linear();
filterSweep(rig);
rig.play.filter.velAmount = 0.0;
rig.play.pitchEnv.enabled = true;
rig.play.pitchEnv.decayFrames = 24000;
rig.play.pitchEnv.peakSemitones = 3.0;
LiveValues hostile = foldLive(rig.play);
hostile.filterKeyTrack = 2.0;
hostile.filterSettings.cutoffNorm = 0.9f;
hostile.filterModAmount = -1.0;
hostile.filterEnv.decayFrames = 4800;
hostile.filterEnv.sustainLevel = 0.0;
hostile.pitchEnvAttackFrames = 4800;
hostile.pitchEnvDecayFrames = 4800;
hostile.pitchEnvPeakSemitones = 24.0;
hostile.adsr.sustainLevel = 0.4;
SampleData quiet = rig, loud = rig, untouched = rig;
LiveParams blockQuiet, blockLoud, blockUntouched;
const Run atQuiet = renderWithLive(quiet, &blockQuiet, 512, 16, 2, &hostile, -1, 64);
const Run atLoud = renderWithLive(loud, &blockLoud, 512, 16, 2, &hostile, -1, 100);
const Run noPublish = renderWithLive(untouched, &blockUntouched, 512, 16, -1, nullptr, -1, 64);
// The publish is not inert: it moved the note it was published into.
double landed = 0.0;
for (std::size_t i = 512 * 3; i < atQuiet.out.size() && i < noPublish.out.size(); ++i) {
landed += std::fabs(static_cast<double>(atQuiet.out[i]) -
static_cast<double>(noPublish.out[i]));
}
CHECK(landed > 1.0);
// ...and through all of it the two velocities differ by exactly the curve's ratio.
const double ratio = rig.velocityCurve.eval(100.0) / rig.velocityCurve.eval(64.0);
CHECK(ratio > 1.5); // the curve really does separate these two velocities
bool proportional = true;
for (std::size_t i = 0; i < atQuiet.out.size() && i < atLoud.out.size(); ++i) {
if (std::fabs(static_cast<double>(atLoud.out[i]) -
static_cast<double>(atQuiet.out[i]) * ratio) > 1e-6) {
proportional = false;
break;
}
}
CHECK(proportional);
}
int main() {
testStageDurationChangeHoldsPhase();
testShortenedStageStillLandsContinuously();
testSustainLevelChangeGlides();
testPitchEnvelopeHoldsPhaseAndGlidesDepth();
testAFreshEnvelopeTakesANewlyDialledStageTimeOutright();
testAFreshPitchEnvelopeTakesTheNewTimesOutright();
testCutoffMoveAcrossPrepareDoesNotStep();
testUnmovedBlockIsByteIdenticalToNoBlockAtAll();
testANoteStartedAfterAPublishSoundsThePublishedEnvelope();
testEveryEnvelopeStageTimeAndLevelMovesTheSoundingNote();
testEveryLiveFilterControlMovesTheSoundingNote();
testOneBlockServesTwoIndependentObservers();
testPitchRatioAndVelocityGainStayLatched();
testVelocityGainSurvivesAHostilePublishThatReallyLands();
if (g_fail == 0) std::printf("live_delivery tests passed\n");
return g_fail == 0 ? 0 : 1;
}
+165
View File
@@ -0,0 +1,165 @@
// Standalone tests for the live-parameter block itself — no VST3, no REAPER, no framework:
// the single fold from the parameter set, the seqlock's coherence under a concurrent writer,
// and the ramp's exact termination. The block's effect on a sounding voice is
// live_delivery_tests.
#include "../src/core/instrument/engine/live_params.h"
#include <atomic>
#include <cstdio>
#include <thread>
#include <type_traits>
using namespace reasampler;
using instrument::engine::LiveParams;
using instrument::engine::LiveValues;
using instrument::engine::ValueRamp;
using instrument::engine::foldLive;
using instrument::engine::liveRampStep;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
// The block is copied wholesale under the seqlock, so anything that owns memory here would be
// a use-after-free waiting for a racing publish.
static_assert(std::is_trivially_copyable_v<LiveValues>, "the live block must stay plain data");
static void testFoldCarriesEveryContinuousControl() {
PlayParams p;
p.adsr = AdsrParams{11, 22, 33, 0.44, 55};
p.filter.enabled = true;
p.filter.settings.cutoffNorm = 0.25f;
p.filter.settings.resonanceNorm = 0.5f;
p.filter.settings.morphNorm = 0.75f;
p.filter.settings.driveNorm = 0.125f;
p.filter.modAmount = -0.6;
p.filter.keyTrack = 1.5;
p.filter.env = AdsrParams{1, 2, 3, 0.4, 5};
p.pitchEnv.attackFrames = 7;
p.pitchEnv.decayFrames = 9;
p.pitchEnv.peakSemitones = -3.5;
const LiveValues v = foldLive(p);
CHECK(v.adsr.attackFrames == 11);
CHECK(v.adsr.holdFrames == 22);
CHECK(v.adsr.decayFrames == 33);
CHECK(v.adsr.sustainLevel == 0.44);
CHECK(v.adsr.releaseFrames == 55);
CHECK(v.filterSettings.cutoffNorm == 0.25f);
CHECK(v.filterSettings.resonanceNorm == 0.5f);
CHECK(v.filterSettings.morphNorm == 0.75f);
CHECK(v.filterSettings.driveNorm == 0.125f);
CHECK(v.filterModAmount == -0.6);
CHECK(v.filterKeyTrack == 1.5);
CHECK(v.filterEnv.decayFrames == 3);
CHECK(v.filterEnv.sustainLevel == 0.4);
CHECK(v.pitchEnvAttackFrames == 7);
CHECK(v.pitchEnvDecayFrames == 9);
CHECK(v.pitchEnvPeakSemitones == -3.5);
}
static void testUnpublishedBlockReadsAsNothing() {
LiveParams block;
LiveValues out;
CHECK(block.read(out) == 0); // never published: the caller must keep its own defaults
LiveValues v;
v.filterModAmount = 0.5;
block.publish(v);
const std::uint32_t first = block.read(out);
CHECK(first != 0);
CHECK(out.filterModAmount == 0.5);
// An unchanged block reports the SAME generation, which is how the reader knows there is
// nothing to push into the voices.
CHECK(block.read(out) == first);
block.publish(v);
CHECK(block.read(out) != first);
}
// The torn-read hazard, exercised rather than argued: a writer publishes multi-field edits as
// fast as it can while a reader copies the block; every observed block must be one the writer
// actually published, never half of one and half of another.
static void testConcurrentReaderNeverSeesAHalfAppliedEdit() {
LiveParams block;
std::atomic<bool> stop{false};
std::atomic<int> observed{0};
std::atomic<int> torn{0};
LiveValues seed;
seed.adsr = AdsrParams{0, 0, 0, 0.0, 0};
block.publish(seed);
std::thread writer([&] {
for (std::int64_t i = 1; !stop.load(std::memory_order_relaxed); ++i) {
LiveValues v;
// One coherent edit: every AHDSR field derives from the same i, so any mixture of
// two edits is detectable from the values alone.
v.adsr = AdsrParams{i, 2 * i, 3 * i, static_cast<double>(i), 5 * i};
v.filterEnv = AdsrParams{4 * i, 5 * i, 6 * i, static_cast<double>(i), 7 * i};
v.filterModAmount = static_cast<double>(i);
block.publish(v);
}
});
for (int n = 0; n < 200000; ++n) {
LiveValues out;
if (block.read(out) == 0) continue; // abandoned read: the caller discards it
observed.fetch_add(1, std::memory_order_relaxed);
const std::int64_t i = out.adsr.attackFrames;
const bool coherent =
out.adsr.holdFrames == 2 * i && out.adsr.decayFrames == 3 * i &&
out.adsr.releaseFrames == 5 * i && out.adsr.sustainLevel == static_cast<double>(i) &&
out.filterEnv.attackFrames == 4 * i && out.filterEnv.holdFrames == 5 * i &&
out.filterEnv.decayFrames == 6 * i && out.filterEnv.releaseFrames == 7 * i &&
out.filterModAmount == static_cast<double>(i);
if (!coherent) torn.fetch_add(1, std::memory_order_relaxed);
}
stop.store(true, std::memory_order_relaxed);
writer.join();
CHECK(torn.load() == 0);
CHECK(observed.load() > 0); // the run proved something only if reads actually landed
}
static void testRampTerminatesExactlyOnTheTarget() {
ValueRamp r;
r.set(0.0);
r.step = 1.0 / 960.0; // the 20 ms step at 48 kHz
r.aim(0.4);
int frames = 0;
while (r.moving() && frames < 100000) { r.tick(); ++frames; }
// Exact equality, not a tolerance: the filter's cutoff-skip fast path compares the value
// itself, so an asymptotic smoother would pin it on the always-re-solve path forever.
CHECK(r.value == 0.4);
CHECK(!r.moving());
CHECK(!r.tick()); // parked: no further movement reported
CHECK(frames > 1); // it glided rather than jumping
}
static void testRampWithNoRateSnaps() {
ValueRamp r;
r.set(0.2);
r.step = liveRampStep(0.0); // rate unknown: never invent one
CHECK(r.step == 0.0);
r.aim(0.9);
CHECK(r.tick());
CHECK(r.value == 0.9);
}
static void testRampStepIsRateDerived() {
CHECK(liveRampStep(48000.0) == 1.0 / (0.020 * 48000.0));
CHECK(liveRampStep(96000.0) == 1.0 / (0.020 * 96000.0));
CHECK(liveRampStep(-1.0) == 0.0);
}
int main() {
testFoldCarriesEveryContinuousControl();
testUnpublishedBlockReadsAsNothing();
testConcurrentReaderNeverSeesAHalfAppliedEdit();
testRampTerminatesExactlyOnTheTarget();
testRampWithNoRateSnaps();
testRampStepIsRateDerived();
if (g_fail == 0) std::printf("live_params tests passed\n");
return g_fail == 0 ? 0 : 1;
}