instrument: deliver continuous playback params live to sounding voices via a seqlock block, holding normalized stage position across time edits
This commit is contained in:
@@ -154,6 +154,39 @@ pitch envelope/curve (AD?) which is off by default."*
|
||||
- **S15/S16 are Tier 0–1 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`
|
||||
(`ui/deck_groups`). Continuous playback controls go live: the six filter tone/modulation
|
||||
knobs, and every stage time and stage level on all three envelopes. Everything else reloads
|
||||
or drops to the voice-param rebuild. Two controls look continuous but are deliberately not
|
||||
live — the pitch key-track and the velocity→cutoff depth feed values a voice latches at
|
||||
note-on (the pitch ratio, the velocity-curve result), so making them live would retune or
|
||||
re-gain a note already struck. The three capture-anchored overrides (root, loop span, start
|
||||
frame) reload because they name positions in the decoded PCM.
|
||||
- **Ownership sits ABOVE every snapshot.** `SampleData::live` is a NON-OWNING pointer to the one
|
||||
block the shell owns per instance; a block owned by a snapshot would leave the drain slot's
|
||||
still-sounding voices deaf to the knob under them. 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 one predicted branch
|
||||
(`filterRamping_`) and no indirection.
|
||||
- **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) 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 (1–32, 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`, the editor's commit-tier routing predicate (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,16 @@ 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. 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
|
||||
`applyLive(snap)` before its first frame. The persisted `params_` is written by the same
|
||||
commit, so `getState` is never stale.
|
||||
- **`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
|
||||
|
||||
@@ -16,12 +16,19 @@ reasampler_test(master_gain LINK master_gain)
|
||||
# Declared before sampler_core because the voice now runs one per sounding note.
|
||||
add_subdirectory(filter)
|
||||
|
||||
# The live-parameter block: the value layer plus its publication, deliberately linking no
|
||||
# engine — the block is a plain value the voice observes, not a thing the engine owns.
|
||||
reasampler_pure_library(live_params
|
||||
SOURCES live_params.cpp
|
||||
LINK PUBLIC peaks velocity_curve filter)
|
||||
reasampler_test(live_params LINK live_params)
|
||||
|
||||
# Two TUs on the engine's own responsibility seam (per-note setup vs. note routing and
|
||||
# block render). The per-sample render half stays inline in voice.h precisely so this TU
|
||||
# boundary costs the hot path nothing.
|
||||
reasampler_pure_library(sampler_core
|
||||
SOURCES voice.cpp voice_engine.cpp
|
||||
LINK PUBLIC peaks pitch_shift velocity_curve filter)
|
||||
LINK PUBLIC peaks pitch_shift velocity_curve filter live_params)
|
||||
# Links only sampler_core: linking more would break the plain-data-boundary proof — a VST3
|
||||
# or REAPER type reaching the core would fail to compile or link here.
|
||||
reasampler_test(sampler_core LINK sampler_core)
|
||||
@@ -29,3 +36,7 @@ reasampler_test(sampler_core LINK sampler_core)
|
||||
# The filter's own seams are covered by the four targets in filter/; this one covers the
|
||||
# integration: pipeline order, per-voice independence, and the off-by-default bit-identity.
|
||||
reasampler_test(sampler_filter LINK sampler_core)
|
||||
|
||||
# Live delivery is the third integration seam over the same engine: what a published block
|
||||
# does to a voice that is already sounding, and what it must leave alone.
|
||||
reasampler_test(live_delivery LINK sampler_core)
|
||||
|
||||
@@ -12,6 +12,41 @@
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// Absorbs a step a live parameter move would otherwise put straight into an evaluator's
|
||||
// output, as an offset that decays to EXACTLY zero — so the at-rest path carries no residue
|
||||
// and the smoother's own branch stays predictably false. Per-frame decay rather than a
|
||||
// wall-clock one, matching the voice's takeover declick; the floor is far below both domains
|
||||
// this is used in (amplitude, and semitones of pitch offset).
|
||||
class StepSmoother {
|
||||
public:
|
||||
// `step` is (level before the change - level after it): adding it back reproduces the
|
||||
// pre-change output exactly on the first frame.
|
||||
void absorb(double step) {
|
||||
offset_ += step;
|
||||
active_ = (offset_ > kFloor || offset_ < -kFloor);
|
||||
if (!active_) offset_ = 0.0;
|
||||
}
|
||||
void clear() { offset_ = 0.0; active_ = false; }
|
||||
bool active() const { return active_; }
|
||||
|
||||
// This frame's offset; decays afterwards, latching inactive at the floor.
|
||||
double advance() {
|
||||
const double out = offset_;
|
||||
offset_ *= kDecay;
|
||||
if (offset_ < kFloor && offset_ > -kFloor) {
|
||||
offset_ = 0.0;
|
||||
active_ = false;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private:
|
||||
static constexpr double kDecay = 0.95;
|
||||
static constexpr double kFloor = 1e-5;
|
||||
double offset_ = 0.0;
|
||||
bool active_ = false;
|
||||
};
|
||||
|
||||
// AHDSR amplitude envelope, sample-based (times in frames), linear segments. A gate:
|
||||
// noteOn() enters Attack; noteOff() enters Release from wherever it is.
|
||||
//
|
||||
@@ -24,6 +59,11 @@ namespace reasampler {
|
||||
// A zero-length attack jumps straight to 1 on the first frame; holdFrames == 0 skips Hold
|
||||
// entirely (the pre-hold-stage ADSR, back-compat); zero decay jumps to sustain; a noteOff
|
||||
// during attack/hold/decay releases from the current partial level, not from sustainLevel.
|
||||
//
|
||||
// stagePos_ is the elapsed position within the current stage. It is a double rather than a
|
||||
// frame count only so applyLive can hold a fractional normalized position; every value it
|
||||
// takes on the un-edited path is integral, so the segment math is bit-identical to the
|
||||
// integer-counter engine.
|
||||
class AdsrEnvelope {
|
||||
public:
|
||||
enum class Stage { Idle, Attack, Hold, Decay, Sustain, Release, Finished };
|
||||
@@ -34,18 +74,43 @@ public:
|
||||
void noteOn() {
|
||||
stage_ = Stage::Attack;
|
||||
level_ = 0.0;
|
||||
framesInStage_ = 0;
|
||||
stagePos_ = 0.0;
|
||||
smooth_.clear();
|
||||
}
|
||||
|
||||
// Gate off: enter Release from the CURRENT level — release-before-sustain releases from
|
||||
// the partial attack/decay level, not from sustainLevel.
|
||||
// the partial attack/decay level, not from sustainLevel. A running smoother deliberately
|
||||
// survives: it is mid-glide, and cutting it here would reintroduce the step it absorbed.
|
||||
void noteOff() {
|
||||
if (stage_ == Stage::Idle || stage_ == Stage::Finished || stage_ == Stage::Release) {
|
||||
return; // already released / not sounding.
|
||||
}
|
||||
releaseFrom_ = level_;
|
||||
stage_ = Stage::Release;
|
||||
framesInStage_ = 0;
|
||||
stagePos_ = 0.0;
|
||||
}
|
||||
|
||||
// Live parameter delivery to a SOUNDING voice. The mid-stage rule is HOLD NORMALIZED
|
||||
// STAGE POSITION: phi = elapsed/duration is kept fixed across the change, so this frame's
|
||||
// level is unchanged by construction and the remainder of the stage takes its share of the
|
||||
// newly-dialled duration. The rule is expressed over normalized position, never over
|
||||
// output level, so a per-segment curve exponent composes with it as a pure map of phi.
|
||||
//
|
||||
// Two cases phi cannot cover, both absorbed by the smoother rather than allowed to step:
|
||||
// a sustain level moved while the voice holds it (sustain is a level, not a timed stage),
|
||||
// and a stage duration dialled to exactly zero mid-stage (the stage ceases to exist and
|
||||
// completes at its terminal level).
|
||||
void applyLive(const AdsrParams& params) {
|
||||
const double before = stageLevel(params_);
|
||||
const double oldDuration = stageDuration(params_);
|
||||
const double newDuration = stageDuration(params);
|
||||
if (newDuration > 0.0) {
|
||||
stagePos_ = (oldDuration > 0.0) ? stagePos_ * (newDuration / oldDuration)
|
||||
: newDuration; // a collapsed stage was complete
|
||||
}
|
||||
params_ = params;
|
||||
const double after = stageLevel(params_);
|
||||
if (after != before) smooth_.absorb(before - after);
|
||||
}
|
||||
|
||||
// Advances one frame and returns the amplitude for THIS frame (before advancing).
|
||||
@@ -53,6 +118,59 @@ public:
|
||||
// the next noteOn). A single, monotonic per-frame step — the caller pulls one value per
|
||||
// output frame.
|
||||
double tick() {
|
||||
const double out = tickStage();
|
||||
return smooth_.active() ? out + smooth_.advance() : out;
|
||||
}
|
||||
|
||||
Stage stage() const { return stage_; }
|
||||
bool finished() const { return stage_ == Stage::Finished; }
|
||||
double level() const { return level_; }
|
||||
|
||||
private:
|
||||
// The level tick() would emit right now under `params`, without advancing anything — the
|
||||
// prediction applyLive compares across the change to size the smoother.
|
||||
double stageLevel(const AdsrParams& params) const {
|
||||
switch (stage_) {
|
||||
case Stage::Attack: {
|
||||
if (params.attackFrames <= 0) return 1.0;
|
||||
const double l = stagePos_ / static_cast<double>(params.attackFrames);
|
||||
return l > 1.0 ? 1.0 : l;
|
||||
}
|
||||
case Stage::Hold:
|
||||
// A zero-length hold falls straight through to Decay on the next tick, whose
|
||||
// level at position 0 is 1.0 — unless decay is zero too, which lands on sustain.
|
||||
if (params.holdFrames > 0) return 1.0;
|
||||
return (params.decayFrames <= 0) ? params.sustainLevel : 1.0;
|
||||
case Stage::Decay: {
|
||||
if (params.decayFrames <= 0) return params.sustainLevel;
|
||||
const double t = stagePos_ / static_cast<double>(params.decayFrames);
|
||||
return 1.0 + (params.sustainLevel - 1.0) * t;
|
||||
}
|
||||
case Stage::Sustain:
|
||||
return params.sustainLevel;
|
||||
case Stage::Release: {
|
||||
if (params.releaseFrames <= 0) return 0.0;
|
||||
const double t = stagePos_ / static_cast<double>(params.releaseFrames);
|
||||
const double l = releaseFrom_ * (1.0 - t);
|
||||
return l < 0.0 ? 0.0 : l;
|
||||
}
|
||||
default:
|
||||
return 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
// The current stage's dialled duration under `params`; 0 for the untimed stages.
|
||||
double stageDuration(const AdsrParams& params) const {
|
||||
switch (stage_) {
|
||||
case Stage::Attack: return static_cast<double>(params.attackFrames);
|
||||
case Stage::Hold: return static_cast<double>(params.holdFrames);
|
||||
case Stage::Decay: return static_cast<double>(params.decayFrames);
|
||||
case Stage::Release: return static_cast<double>(params.releaseFrames);
|
||||
default: return 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
double tickStage() {
|
||||
switch (stage_) {
|
||||
case Stage::Idle:
|
||||
case Stage::Finished:
|
||||
@@ -63,16 +181,15 @@ public:
|
||||
if (params_.attackFrames <= 0) {
|
||||
level_ = 1.0;
|
||||
} else {
|
||||
level_ = static_cast<double>(framesInStage_) /
|
||||
static_cast<double>(params_.attackFrames);
|
||||
level_ = stagePos_ / static_cast<double>(params_.attackFrames);
|
||||
if (level_ > 1.0) level_ = 1.0;
|
||||
}
|
||||
const double out = level_;
|
||||
++framesInStage_;
|
||||
if (framesInStage_ >= params_.attackFrames) {
|
||||
stagePos_ += 1.0;
|
||||
if (stagePos_ >= static_cast<double>(params_.attackFrames)) {
|
||||
// holdFrames == 0 falls straight through Hold on the next tick to Decay.
|
||||
stage_ = Stage::Hold;
|
||||
framesInStage_ = 0;
|
||||
stagePos_ = 0.0;
|
||||
level_ = 1.0;
|
||||
}
|
||||
return out;
|
||||
@@ -84,18 +201,19 @@ public:
|
||||
// extra sample.
|
||||
if (params_.holdFrames <= 0) {
|
||||
stage_ = Stage::Decay;
|
||||
framesInStage_ = 0;
|
||||
stagePos_ = 0.0;
|
||||
level_ = 1.0;
|
||||
// Single re-dispatch into Decay (bounded: Hold->Decay only, not general
|
||||
// recursion).
|
||||
return tick();
|
||||
// recursion). Re-enters the STAGE evaluator, never tick(), so a running
|
||||
// smoother is applied exactly once per frame.
|
||||
return tickStage();
|
||||
}
|
||||
level_ = 1.0;
|
||||
const double out = level_;
|
||||
++framesInStage_;
|
||||
if (framesInStage_ >= params_.holdFrames) {
|
||||
stagePos_ += 1.0;
|
||||
if (stagePos_ >= static_cast<double>(params_.holdFrames)) {
|
||||
stage_ = Stage::Decay;
|
||||
framesInStage_ = 0;
|
||||
stagePos_ = 0.0;
|
||||
level_ = 1.0;
|
||||
}
|
||||
return out;
|
||||
@@ -105,15 +223,14 @@ public:
|
||||
if (params_.decayFrames <= 0) {
|
||||
level_ = params_.sustainLevel;
|
||||
} else {
|
||||
const double t = static_cast<double>(framesInStage_) /
|
||||
static_cast<double>(params_.decayFrames);
|
||||
const double t = stagePos_ / static_cast<double>(params_.decayFrames);
|
||||
level_ = 1.0 + (params_.sustainLevel - 1.0) * t;
|
||||
}
|
||||
const double out = level_;
|
||||
++framesInStage_;
|
||||
if (framesInStage_ >= params_.decayFrames) {
|
||||
stagePos_ += 1.0;
|
||||
if (stagePos_ >= static_cast<double>(params_.decayFrames)) {
|
||||
stage_ = Stage::Sustain;
|
||||
framesInStage_ = 0;
|
||||
stagePos_ = 0.0;
|
||||
level_ = params_.sustainLevel;
|
||||
}
|
||||
return out;
|
||||
@@ -129,13 +246,12 @@ public:
|
||||
stage_ = Stage::Finished;
|
||||
return 0.0;
|
||||
}
|
||||
const double t = static_cast<double>(framesInStage_) /
|
||||
static_cast<double>(params_.releaseFrames);
|
||||
const double t = stagePos_ / static_cast<double>(params_.releaseFrames);
|
||||
level_ = releaseFrom_ * (1.0 - t);
|
||||
if (level_ < 0.0) level_ = 0.0;
|
||||
const double out = level_;
|
||||
++framesInStage_;
|
||||
if (framesInStage_ >= params_.releaseFrames) {
|
||||
stagePos_ += 1.0;
|
||||
if (stagePos_ >= static_cast<double>(params_.releaseFrames)) {
|
||||
stage_ = Stage::Finished;
|
||||
level_ = 0.0;
|
||||
}
|
||||
@@ -145,16 +261,12 @@ public:
|
||||
return 0.0; // unreachable; silences a warning.
|
||||
}
|
||||
|
||||
Stage stage() const { return stage_; }
|
||||
bool finished() const { return stage_ == Stage::Finished; }
|
||||
double level() const { return level_; }
|
||||
|
||||
private:
|
||||
AdsrParams params_;
|
||||
Stage stage_ = Stage::Idle;
|
||||
double level_ = 0.0;
|
||||
std::int64_t framesInStage_ = 0;
|
||||
double stagePos_ = 0.0;
|
||||
double releaseFrom_ = 0.0; // level at the moment noteOff() was called
|
||||
StepSmoother smooth_;
|
||||
};
|
||||
|
||||
// A stateless-shape amplitude function over the play span, evaluated at a source-frame
|
||||
@@ -233,34 +345,62 @@ private:
|
||||
// (Varispeed) or a shift-amount add (Preserve).
|
||||
class PitchEnvelope {
|
||||
public:
|
||||
void configure(const PitchEnvParams& params) { params_ = params; pos_ = 0; }
|
||||
void noteOn() { pos_ = 0; }
|
||||
void configure(const PitchEnvParams& params) { params_ = params; pos_ = 0.0; }
|
||||
void noteOn() { pos_ = 0.0; smooth_.clear(); }
|
||||
|
||||
// Live parameter delivery, same rule as AdsrEnvelope::applyLive: hold the normalized
|
||||
// position within whichever leg the envelope is in, and absorb the depth step (peak is a
|
||||
// level, not a duration). `enabled` is a discrete toggle and travels by reload, so it is
|
||||
// deliberately not a parameter here.
|
||||
void applyLive(std::int64_t attackFrames, std::int64_t decayFrames,
|
||||
double peakSemitones) {
|
||||
const double before = offsetAt(params_);
|
||||
const double a = params_.attackFrames > 0 ? static_cast<double>(params_.attackFrames) : 0.0;
|
||||
const double d = params_.decayFrames > 0 ? static_cast<double>(params_.decayFrames) : 0.0;
|
||||
const double na = attackFrames > 0 ? static_cast<double>(attackFrames) : 0.0;
|
||||
const double nd = decayFrames > 0 ? static_cast<double>(decayFrames) : 0.0;
|
||||
if (pos_ < a) {
|
||||
pos_ = (na > 0.0) ? pos_ * (na / a) : na;
|
||||
} else if (pos_ < a + d) {
|
||||
pos_ = (nd > 0.0) ? na + (pos_ - a) * (nd / d) : na + nd;
|
||||
} else {
|
||||
pos_ = na + nd; // already past the envelope: stay past it under the new times
|
||||
}
|
||||
params_.attackFrames = attackFrames;
|
||||
params_.decayFrames = decayFrames;
|
||||
params_.peakSemitones = peakSemitones;
|
||||
const double after = offsetAt(params_);
|
||||
if (after != before) smooth_.absorb(before - after);
|
||||
}
|
||||
|
||||
double tick() {
|
||||
if (!params_.enabled) return 0.0;
|
||||
|
||||
const std::int64_t a = params_.attackFrames > 0 ? params_.attackFrames : 0;
|
||||
const std::int64_t d = params_.decayFrames > 0 ? params_.decayFrames : 0;
|
||||
const double peak = params_.peakSemitones;
|
||||
|
||||
double offset;
|
||||
if (pos_ < a) {
|
||||
// Attack: 0 -> peak over attackFrames (rise into the peak).
|
||||
offset = peak * (static_cast<double>(pos_) / static_cast<double>(a));
|
||||
} else if (pos_ < a + d) {
|
||||
// Decay: peak -> 0 over decayFrames (settle to base pitch).
|
||||
const double t = static_cast<double>(pos_ - a) / static_cast<double>(d);
|
||||
offset = peak * (1.0 - t);
|
||||
} else {
|
||||
offset = 0.0; // past attack+decay: at base pitch forever.
|
||||
}
|
||||
++pos_;
|
||||
return offset;
|
||||
const double offset = offsetAt(params_);
|
||||
pos_ += 1.0;
|
||||
return smooth_.active() ? offset + smooth_.advance() : offset;
|
||||
}
|
||||
|
||||
private:
|
||||
// The semitone offset at the current position under `params` — the shared evaluator for
|
||||
// both tick() and applyLive's before/after comparison.
|
||||
double offsetAt(const PitchEnvParams& params) const {
|
||||
if (!params.enabled) return 0.0;
|
||||
const double a = params.attackFrames > 0 ? static_cast<double>(params.attackFrames) : 0.0;
|
||||
const double d = params.decayFrames > 0 ? static_cast<double>(params.decayFrames) : 0.0;
|
||||
if (pos_ < a) {
|
||||
// Attack: 0 -> peak over attackFrames (rise into the peak).
|
||||
return params.peakSemitones * (pos_ / a);
|
||||
}
|
||||
if (pos_ < a + d) {
|
||||
// Decay: peak -> 0 over decayFrames (settle to base pitch).
|
||||
return params.peakSemitones * (1.0 - (pos_ - a) / d);
|
||||
}
|
||||
return 0.0; // past attack+decay: at base pitch forever.
|
||||
}
|
||||
|
||||
PitchEnvParams params_;
|
||||
std::int64_t pos_ = 0;
|
||||
double pos_ = 0.0;
|
||||
StepSmoother smooth_;
|
||||
};
|
||||
|
||||
} // namespace reasampler
|
||||
|
||||
@@ -234,6 +234,13 @@ topology.
|
||||
genuinely ~0.02% low at 48 kHz, widening to ~0.03% low at 192 kHz — real coefficient
|
||||
narrowing, not measurement-window noise, and comfortably inside the test's 0.4% tolerance
|
||||
either way. Do not reintroduce a direct-form kernel.
|
||||
- **A coefficient jump here produces no isolated output spike, measured.** Preserving state
|
||||
across `prepare()` is strong enough that even an instantaneous cutoff/Q/morph jump leaves the
|
||||
boundary frame inside the signal's own frame-to-frame range — a single-frame-spike metric
|
||||
cannot detect one. What the caller's per-frame glide prevents is therefore the *parameter*
|
||||
arriving as a step (and the zipper of repeated steps at control rate), not a click at the
|
||||
jump itself. A test claiming to prove the glide must measure how fast the output diverges,
|
||||
not how far one frame moves; `live_delivery_tests` does.
|
||||
- **`prepare()` deliberately does not clear state** — a live parameter move must glide, not
|
||||
click. Call `reset()` at note-on. **Exception: the non-positive-rate bypass path.** There,
|
||||
`a1=1, a2=a3=0` makes both state updates the exact identity and `bypassMix()` never reads
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// live_params.cpp — the fold from the parameter set to the live block, and the ramp-step law.
|
||||
// See live_params.h for the publication contract.
|
||||
|
||||
#include "core/instrument/engine/live_params.h"
|
||||
|
||||
namespace reasampler::instrument::engine {
|
||||
|
||||
LiveValues foldLive(const PlayParams& params) {
|
||||
LiveValues v;
|
||||
v.filterSettings = params.filter.settings;
|
||||
v.filterModAmount = params.filter.modAmount;
|
||||
v.filterKeyTrack = params.filter.keyTrack;
|
||||
v.filterEnv = params.filter.env;
|
||||
v.adsr = params.adsr;
|
||||
v.pitchEnvAttackFrames = params.pitchEnv.attackFrames;
|
||||
v.pitchEnvDecayFrames = params.pitchEnv.decayFrames;
|
||||
v.pitchEnvPeakSemitones = params.pitchEnv.peakSemitones;
|
||||
return v;
|
||||
}
|
||||
|
||||
double liveRampStep(double sampleRate) {
|
||||
if (!(sampleRate > 0.0)) return 0.0; // also catches NaN
|
||||
return 1.0 / (kLiveRampSeconds * sampleRate);
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::engine
|
||||
@@ -0,0 +1,118 @@
|
||||
#pragma once
|
||||
// live_params.h — the live playback-parameter block: the plain value bundle the audio thread
|
||||
// observes once per BLOCK, the single-writer seqlock that publishes it without a lock or a
|
||||
// torn read, the ONE fold from PlayParams that keeps the two representations in step, and the
|
||||
// per-frame ramp that keeps a block-rate step inaudible. Ownership belongs above every
|
||||
// instrument snapshot (see SampleData::live).
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <type_traits>
|
||||
|
||||
#include "core/instrument/engine/play_params.h"
|
||||
|
||||
namespace reasampler::instrument::engine {
|
||||
|
||||
// Full-scale glide time for a live control move (wall-clock seconds), so every smoothed
|
||||
// control in the program settles on the one time base the post-mixer gain ramp already uses.
|
||||
inline constexpr double kLiveRampSeconds = 0.020;
|
||||
|
||||
// Every continuously-valued playback control, in the SAME domains the engine latches at
|
||||
// note-on (normalized control positions, envelope times already resolved to frames). What is
|
||||
// deliberately absent is as load-bearing as what is present: velocity and everything derived
|
||||
// from it, the note number and its pitch ratio, and the decoded PCM are facts about the note
|
||||
// event, not controls, and stay latched at note-on. The discrete toggles (play mode, pitch
|
||||
// engine, filter enable/law, pitch-envelope enable, channel mode) travel by reload instead.
|
||||
//
|
||||
// morphLaw rides inside filterSettings only because it is cheaper to carry the whole struct to
|
||||
// the filter's prepare() than to splice it back; it changes only across a reload, which
|
||||
// republishes this block, so the two can never disagree.
|
||||
struct LiveValues {
|
||||
filter::FilterSettings filterSettings{};
|
||||
double filterModAmount = 0.0;
|
||||
double filterKeyTrack = 0.0;
|
||||
AdsrParams filterEnv{};
|
||||
AdsrParams adsr{};
|
||||
std::int64_t pitchEnvAttackFrames = 0;
|
||||
std::int64_t pitchEnvDecayFrames = 0;
|
||||
double pitchEnvPeakSemitones = 0.0;
|
||||
};
|
||||
|
||||
// The seqlock copies the block as raw bytes, which is only defensible for a plain value type.
|
||||
static_assert(std::is_trivially_copyable_v<LiveValues>,
|
||||
"the live block is copied under a seqlock — it must stay a plain value");
|
||||
|
||||
// The ONE derivation of the live block from the parameter set. Every publisher goes through
|
||||
// here so there is a single site to keep in step with PlayParams.
|
||||
LiveValues foldLive(const PlayParams& params);
|
||||
|
||||
// Single-writer / single-reader seqlock. The writer publishes a whole block between an odd
|
||||
// and an even generation; the reader copies the block and re-checks the generation, retrying
|
||||
// a bounded number of times, so it can never act on a half-applied edit. Wait-free for the
|
||||
// reader: after the retry budget it reports "nothing new" and the caller keeps its last good
|
||||
// snapshot rather than spinning on the audio thread.
|
||||
//
|
||||
// The writer interface deliberately assumes NO particular thread beyond single-writer, so a
|
||||
// host's own parameter-change queue (delivered on the audio thread with sample offsets) can
|
||||
// drive it later without a redesign.
|
||||
class LiveParams {
|
||||
public:
|
||||
// A generation of 0 means "never published"; the first publish lands on 2.
|
||||
void publish(const LiveValues& values) {
|
||||
const std::uint32_t gen = seq_.load(std::memory_order_relaxed);
|
||||
seq_.store(gen + 1, std::memory_order_relaxed); // odd: a write is in progress
|
||||
std::atomic_thread_fence(std::memory_order_release);
|
||||
values_ = values;
|
||||
std::atomic_thread_fence(std::memory_order_release);
|
||||
seq_.store(gen + 2, std::memory_order_release); // even: complete and coherent
|
||||
}
|
||||
|
||||
// Copies the block into `out` and returns the generation actually observed, or 0 when
|
||||
// nothing has been published yet or the retry budget ran out (in which case `out` may hold
|
||||
// a torn copy and MUST be discarded — compare the return against 0 before using it).
|
||||
std::uint32_t read(LiveValues& out, int maxAttempts = 4) const {
|
||||
for (int attempt = 0; attempt < maxAttempts; ++attempt) {
|
||||
const std::uint32_t before = seq_.load(std::memory_order_acquire);
|
||||
if (before == 0) return 0; // never published
|
||||
if ((before & 1u) != 0u) continue; // writer mid-update
|
||||
std::atomic_thread_fence(std::memory_order_acquire);
|
||||
out = values_;
|
||||
std::atomic_thread_fence(std::memory_order_acquire);
|
||||
if (seq_.load(std::memory_order_relaxed) == before) return before;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private:
|
||||
std::atomic<std::uint32_t> seq_{0};
|
||||
LiveValues values_{};
|
||||
};
|
||||
|
||||
// Linear per-frame glide with EXACT termination: once the target is within one step the value
|
||||
// becomes the target itself. An asymptotic smoother would leave the value forever a hair off,
|
||||
// pinning the filter's exact-equality cutoff skip on the always-re-solve path; this returns to
|
||||
// the skip path the moment the move completes. A non-positive step snaps (no rate known yet).
|
||||
struct ValueRamp {
|
||||
double value = 0.0;
|
||||
double target = 0.0;
|
||||
double step = 0.0;
|
||||
|
||||
bool moving() const { return value != target; }
|
||||
void set(double v) { value = v; target = v; }
|
||||
void aim(double t) { target = t; }
|
||||
|
||||
// Advances one frame; returns whether the value actually moved.
|
||||
bool tick() {
|
||||
if (value == target) return false;
|
||||
const double delta = target - value;
|
||||
if (step <= 0.0 || (delta <= step && delta >= -step)) value = target;
|
||||
else value += (delta > 0.0) ? step : -step;
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
// Per-frame ramp step for a control whose full travel is 1.0, at `sampleRate`. A non-positive
|
||||
// rate yields 0 — the ramp then snaps rather than inventing a rate.
|
||||
double liveRampStep(double sampleRate);
|
||||
|
||||
} // namespace reasampler::instrument::engine
|
||||
@@ -14,6 +14,8 @@
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
namespace instrument::engine { class LiveParams; } // live_params.h; SampleData holds a pointer
|
||||
|
||||
using audio::AudioSample;
|
||||
using instrument::engine::VelocityCurve;
|
||||
|
||||
@@ -166,6 +168,14 @@ struct SampleData {
|
||||
|
||||
PlayParams play;
|
||||
|
||||
// The live-parameter block a sounding voice tracks, or null for the bare latched engine
|
||||
// (the default — with no block attached the core is byte-identical to the pre-live one).
|
||||
// NON-OWNING and deliberately not per-snapshot: the shell owns ONE block that outlives
|
||||
// every instrument snapshot, so a voice still ringing out of the drain slot follows the
|
||||
// same knob as a live one. That is the desired behaviour — it is the note the user is
|
||||
// hearing. Do not "fix" it by moving ownership into the snapshot.
|
||||
const instrument::engine::LiveParams* live = nullptr;
|
||||
|
||||
// A framesR of a different length than frames is treated as absent — a malformed pair
|
||||
// never half-plays.
|
||||
int channelCount() const {
|
||||
|
||||
@@ -92,24 +92,41 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
|
||||
pitchEnv_.configure(p.pitchEnv);
|
||||
pitchEnv_.noteOn();
|
||||
|
||||
// A restart lands every live glide back on the new note's own values, at a step derived
|
||||
// from this sample's rate rather than any assumed one.
|
||||
filterRamping_ = false;
|
||||
const double rampStep = instrument::engine::liveRampStep(
|
||||
static_cast<double>(sample.sampleRate));
|
||||
rBaseCutoff_.step = rampStep;
|
||||
rModAmount_.step = rampStep;
|
||||
rResonance_.step = rampStep;
|
||||
rMorph_.step = rampStep;
|
||||
rDrive_.step = rampStep;
|
||||
|
||||
// Filter: reset() clears integrator state for the new note (prepare() preserves it —
|
||||
// voice_filter.h / filter/CLAUDE.md). Velocity maps through the curve once here, off the
|
||||
// per-frame path, exactly as the amp's velocityGain_ does.
|
||||
filterOn_ = p.filter.enabled;
|
||||
if (filterOn_) {
|
||||
filterSettings_ = p.filter.settings;
|
||||
filterCutoffNorm_ = static_cast<double>(p.filter.settings.cutoffNorm);
|
||||
filterModAmount_ = p.filter.modAmount;
|
||||
filterKeyTrack_ = p.filter.keyTrack;
|
||||
filterVelOffset_ =
|
||||
p.filter.velAmount * p.filter.velocityCurve.eval(static_cast<double>(velocity));
|
||||
filterRate_ = static_cast<double>(sample.sampleRate);
|
||||
rModAmount_.set(p.filter.modAmount);
|
||||
rResonance_.set(static_cast<double>(p.filter.settings.resonanceNorm));
|
||||
rMorph_.set(static_cast<double>(p.filter.settings.morphNorm));
|
||||
rDrive_.set(static_cast<double>(p.filter.settings.driveNorm));
|
||||
filterEnv_.configure(p.filter.env);
|
||||
filterEnv_.noteOn();
|
||||
filter_.reset();
|
||||
updateFilterCutoffBase(note);
|
||||
// The note's ONE full solve — Q, morph and drive are constants for its lifetime, so
|
||||
// every later re-solve is the cheap cutoff-only path. A modulated voice supersedes this
|
||||
// cutoff in tickFilterCutoff on its first frame, before any sample reaches the kernel.
|
||||
// The note's ONE full solve — Q, morph and drive are constants for its lifetime unless
|
||||
// a live move glides them, so every later re-solve is the cheap cutoff-only path. A
|
||||
// modulated voice supersedes this cutoff in tickFilterCutoff on its first frame,
|
||||
// before any sample reaches the kernel.
|
||||
instrument::engine::filter::FilterSettings s = p.filter.settings;
|
||||
s.cutoffNorm = filterBaseCutoff_;
|
||||
filter_.prepare(s, filterRate_);
|
||||
@@ -178,6 +195,40 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
|
||||
ratio_ = baseRatio_; // seeded; advanceFrame recomputes per frame under the active engine.
|
||||
}
|
||||
|
||||
void Voice::applyLive(const instrument::engine::LiveValues& live, bool snap) {
|
||||
// Gate's amplitude envelope is the AHDSR; Trigger's fade shape is anchored to a play span
|
||||
// resolved at note-on and is not a live control, so it is deliberately untouched here.
|
||||
if (playMode_ == PlayMode::Gate) env_.applyLive(live.adsr);
|
||||
pitchEnv_.applyLive(live.pitchEnvAttackFrames, live.pitchEnvDecayFrames,
|
||||
live.pitchEnvPeakSemitones);
|
||||
if (!filterOn_) return; // filter enable is a discrete toggle: it travels by reload
|
||||
|
||||
filterEnv_.applyLive(live.filterEnv);
|
||||
filterCutoffNorm_ = static_cast<double>(live.filterSettings.cutoffNorm);
|
||||
filterKeyTrack_ = live.filterKeyTrack;
|
||||
filterSettings_.morphLaw = live.filterSettings.morphLaw;
|
||||
const double baseTarget = filterCutoffBaseTarget(note_);
|
||||
if (snap) {
|
||||
rBaseCutoff_.set(baseTarget);
|
||||
rModAmount_.set(live.filterModAmount);
|
||||
rResonance_.set(static_cast<double>(live.filterSettings.resonanceNorm));
|
||||
rMorph_.set(static_cast<double>(live.filterSettings.morphNorm));
|
||||
rDrive_.set(static_cast<double>(live.filterSettings.driveNorm));
|
||||
filterBaseCutoff_ = static_cast<float>(baseTarget);
|
||||
filterModAmount_ = live.filterModAmount;
|
||||
filterRamping_ = false;
|
||||
prepareFilterFromRamps();
|
||||
return;
|
||||
}
|
||||
rBaseCutoff_.aim(baseTarget);
|
||||
rModAmount_.aim(live.filterModAmount);
|
||||
rResonance_.aim(static_cast<double>(live.filterSettings.resonanceNorm));
|
||||
rMorph_.aim(static_cast<double>(live.filterSettings.morphNorm));
|
||||
rDrive_.aim(static_cast<double>(live.filterSettings.driveNorm));
|
||||
filterRamping_ = rBaseCutoff_.moving() || rModAmount_.moving() || rResonance_.moving() ||
|
||||
rMorph_.moving() || rDrive_.moving();
|
||||
}
|
||||
|
||||
void Voice::retune(int note) {
|
||||
// Mono legato takeover: move the pitch, touch NOTHING else — the amplitude envelope keeps
|
||||
// running (no re-attack), the read head keeps its position, the shifter keeps its ring
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
#include "core/instrument/engine/envelopes.h"
|
||||
#include "core/instrument/engine/filter/filter_params.h"
|
||||
#include "core/instrument/engine/filter/voice_filter.h"
|
||||
#include "core/instrument/engine/live_params.h"
|
||||
#include "core/instrument/engine/pitch_shift.h"
|
||||
#include "core/instrument/engine/play_params.h"
|
||||
#include "core/instrument/engine/velocity_curve.h"
|
||||
@@ -115,6 +116,16 @@ public:
|
||||
// meaningful while active().
|
||||
PitchEngine pitchEngine() const { return pitchEngine_; }
|
||||
|
||||
// Applies the live-parameter block to a voice that is already sounding (or, with `snap`,
|
||||
// to one just started). Called at BLOCK boundaries by VoiceEngine — never per frame — so
|
||||
// the per-sample shape is unchanged; every continuous control glides toward its new value
|
||||
// from here rather than jumping to it. `snap` takes the values outright: a fresh note has
|
||||
// nothing to glide from, and its latched copy may predate the newest edit.
|
||||
//
|
||||
// What is NOT here is the point: velocity and its curve result, the note number and the
|
||||
// pitch ratio, and the decoded PCM stay latched at note-on.
|
||||
void applyLive(const instrument::engine::LiveValues& live, bool snap);
|
||||
|
||||
// Pre-sizes this voice's Preserve pitch shifters (both channels) to `windowFrames`, off
|
||||
// the audio thread (allocates; also sizes the prime scratch buffer), so start() — which
|
||||
// runs inside process() — never allocates. <= 1 leaves the shifters pass-through.
|
||||
@@ -196,9 +207,10 @@ private:
|
||||
}
|
||||
|
||||
// The cutoff position before the envelope: the stored knob position plus this note's
|
||||
// velocity offset and key-tracking. Recomputed at note-on and at a legato retune (both
|
||||
// move the note), never per frame.
|
||||
void updateFilterCutoffBase(int note) {
|
||||
// velocity offset and key-tracking. Evaluated at note-on, at a legato retune (both move
|
||||
// the note), and when a live move changes the knob position or the key-track depth —
|
||||
// never per frame.
|
||||
double filterCutoffBaseTarget(int note) const {
|
||||
double base = filterCutoffNorm_ + filterVelOffset_;
|
||||
if (filterKeyTrack_ != 0.0 && sample_ != nullptr) {
|
||||
base += filterKeyTrack_ *
|
||||
@@ -207,10 +219,52 @@ private:
|
||||
}
|
||||
if (base < 0.0) base = 0.0;
|
||||
if (base > 1.0) base = 1.0;
|
||||
return base;
|
||||
}
|
||||
|
||||
// Takes the base outright (no glide) — a note-on or a retune is a new note position, not a
|
||||
// knob move, so there is nothing to glide from.
|
||||
void updateFilterCutoffBase(int note) {
|
||||
const double base = filterCutoffBaseTarget(note);
|
||||
rBaseCutoff_.set(base);
|
||||
filterBaseCutoff_ = static_cast<float>(base);
|
||||
filterSolved_ = false; // forces the next frame to solve
|
||||
}
|
||||
|
||||
// The full solve, from the tone-control ramps' current values, at the current base cutoff —
|
||||
// the same shape start() performs, and it leaves the same solved-cutoff bookkeeping behind
|
||||
// so an unmoved live block reproduces start()'s state exactly. State is preserved across
|
||||
// prepare() by contract (voice_filter.h), which is what makes a live tone move glide
|
||||
// rather than click.
|
||||
void prepareFilterFromRamps() {
|
||||
filterSettings_.resonanceNorm = static_cast<float>(rResonance_.value);
|
||||
filterSettings_.morphNorm = static_cast<float>(rMorph_.value);
|
||||
filterSettings_.driveNorm = static_cast<float>(rDrive_.value);
|
||||
filterSettings_.cutoffNorm = filterBaseCutoff_;
|
||||
filter_.prepare(filterSettings_, filterRate_);
|
||||
filterSolvedCutoff_ = filterBaseCutoff_;
|
||||
filterSolved_ = true;
|
||||
}
|
||||
|
||||
// Advances the five live filter-control glides by one frame. Q, morph and drive are
|
||||
// prepare()-cadence constants, so a move on any of them costs the full solve while the
|
||||
// glide runs (~20 ms) and nothing once it lands; the base cutoff and the mod depth feed
|
||||
// tickFilterCutoff's own cheap cutoff-only solve instead.
|
||||
void tickFilterRamps() {
|
||||
bool tone = false;
|
||||
if (rResonance_.tick()) tone = true;
|
||||
if (rMorph_.tick()) tone = true;
|
||||
if (rDrive_.tick()) tone = true;
|
||||
if (rBaseCutoff_.tick()) {
|
||||
filterBaseCutoff_ = static_cast<float>(rBaseCutoff_.value);
|
||||
filterSolved_ = false;
|
||||
}
|
||||
if (rModAmount_.tick()) filterModAmount_ = rModAmount_.value;
|
||||
if (tone) prepareFilterFromRamps();
|
||||
filterRamping_ = rResonance_.moving() || rMorph_.moving() || rDrive_.moving() ||
|
||||
rBaseCutoff_.moving() || rModAmount_.moving();
|
||||
}
|
||||
|
||||
// Seeds the takeover compensation on the first frame after a restart: the ramp is the
|
||||
// actual discontinuity — (pre-cut reference - the new voice's raw output this frame) —
|
||||
// applied ungated so the boundary frame reproduces the old level exactly.
|
||||
@@ -397,6 +451,7 @@ private:
|
||||
// Skipped whole when disengaged (the default), so an un-filtered render stays
|
||||
// bit-identical to the pre-filter engine.
|
||||
if (filterOn_) {
|
||||
if (filterRamping_) tickFilterRamps(); // false at rest: one predicted branch
|
||||
tickFilterCutoff();
|
||||
outL = static_cast<double>(filter_.process(0, static_cast<float>(outL)));
|
||||
// Dual-mono feeds channel 1 the value channel 0 already carried, so mirroring the
|
||||
@@ -486,10 +541,24 @@ private:
|
||||
double filterModAmount_ = 0.0;
|
||||
double filterVelOffset_ = 0.0; // velAmount * velocityCurve.eval(velocity), fixed per note
|
||||
double filterKeyTrack_ = 0.0;
|
||||
instrument::engine::filter::FilterSettings filterSettings_{}; // the note's tone controls
|
||||
float filterBaseCutoff_ = 1.0f; // cutoff before the envelope, clamped
|
||||
float filterSolvedCutoff_ = 1.0f; // the position the live coefficients were solved from
|
||||
bool filterSolved_ = false; // false forces the next frame to solve
|
||||
|
||||
// Live-parameter glides (live_params.h). Every one is parked at its target unless a move
|
||||
// is in flight, so filterRamping_ is false and the per-sample path keeps the pre-live
|
||||
// engine's exact shape. All five live in the filter's control domains — the envelopes
|
||||
// need no ramp here, because holding normalized stage position is continuous by
|
||||
// construction and their two genuine level steps are absorbed inside AdsrEnvelope /
|
||||
// PitchEnvelope themselves.
|
||||
bool filterRamping_ = false;
|
||||
instrument::engine::ValueRamp rBaseCutoff_;
|
||||
instrument::engine::ValueRamp rModAmount_;
|
||||
instrument::engine::ValueRamp rResonance_;
|
||||
instrument::engine::ValueRamp rMorph_;
|
||||
instrument::engine::ValueRamp rDrive_;
|
||||
|
||||
// pitchEngine_ selects Varispeed (ratio bias) vs Preserve (source-rate read + shifter).
|
||||
// shiftL_/shiftR_ transpose the Preserve output per channel. pitchEnv_ rides either engine.
|
||||
//
|
||||
|
||||
@@ -33,6 +33,32 @@ VoiceEngine::VoiceEngine(std::size_t maxVoices, const SampleData& sample,
|
||||
}
|
||||
}
|
||||
|
||||
bool VoiceEngine::refreshLive() {
|
||||
const instrument::engine::LiveParams* block = sample_.live;
|
||||
if (block == nullptr) return false; // bare engine: the latched note-on values stand
|
||||
instrument::engine::LiveValues observed;
|
||||
const std::uint32_t generation = block->read(observed);
|
||||
if (generation == 0 || generation == liveGeneration_) return false;
|
||||
liveGeneration_ = generation;
|
||||
live_ = observed;
|
||||
haveLive_ = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
void VoiceEngine::applyLiveToActive() {
|
||||
if (!refreshLive()) return;
|
||||
for (Voice& voice : voices_) {
|
||||
if (voice.active()) voice.applyLive(live_, /*snap=*/false);
|
||||
}
|
||||
}
|
||||
|
||||
void VoiceEngine::startVoice(Voice& voice, int note, int velocity) {
|
||||
refreshLive();
|
||||
voice.start(note, velocity, sample_, /*declickTakeover=*/takeoverDeclick_);
|
||||
if (haveLive_) voice.applyLive(live_, /*snap=*/true);
|
||||
voice.setStartOrder(nextStartOrder_++);
|
||||
}
|
||||
|
||||
std::size_t VoiceEngine::activePreserveVoices() const {
|
||||
// Count only voices that are SOUNDING A NOTE (playable span still running), not voices
|
||||
// that have finished their note but are still ringing out a declick tail. A ramp-only
|
||||
@@ -122,8 +148,7 @@ std::size_t VoiceEngine::monoNoteOn(int note, int velocity) {
|
||||
// RETRIGGER takeover / first note of a phrase: (re)start the voice. The declick opt-in
|
||||
// rides every mono restart; start() self-gates it on the voice being ACTIVE, so a
|
||||
// first-note fresh start never ramps — only a hard cut of a sounding tone.
|
||||
v.start(note, velocity, sample_, /*declickTakeover=*/takeoverDeclick_);
|
||||
v.setStartOrder(nextStartOrder_++);
|
||||
startVoice(v, note, velocity);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -150,8 +175,7 @@ void VoiceEngine::monoNoteOff(int note) {
|
||||
// Retrigger fallback: re-strike the fallen-back-to note at its own original velocity.
|
||||
// Peer restart site of monoNoteOn's takeover — same declick opt-in (the fallback also
|
||||
// hard-cuts the sounding tone).
|
||||
v.start(fb.note, fb.velocity, sample_, /*declickTakeover=*/takeoverDeclick_);
|
||||
v.setStartOrder(nextStartOrder_++);
|
||||
startVoice(v, fb.note, fb.velocity);
|
||||
}
|
||||
|
||||
std::size_t VoiceEngine::noteOn(int note, int velocity) {
|
||||
@@ -174,8 +198,7 @@ std::size_t VoiceEngine::noteOn(int note, int velocity) {
|
||||
// active, so a free-voice start never ramps — only an at-cap steal, which is the same hard
|
||||
// cut of a sounding tone as the mono retrig takeover.
|
||||
const std::size_t v = allocateVoice();
|
||||
voices_[v].start(note, velocity, sample_, /*declickTakeover=*/takeoverDeclick_);
|
||||
voices_[v].setStartOrder(nextStartOrder_++);
|
||||
startVoice(voices_[v], note, velocity);
|
||||
return v;
|
||||
}
|
||||
|
||||
@@ -225,6 +248,7 @@ void VoiceEngine::render(AudioSample* out, std::size_t frameCount) {
|
||||
// The VST3 process callback hands us the host's output channel buffer here, so the
|
||||
// audio thread never touches the heap.
|
||||
if (out == nullptr || frameCount == 0) return;
|
||||
applyLiveToActive(); // block boundary, once — never inside the frame loop
|
||||
for (Voice& voice : voices_) {
|
||||
if (!voice.active()) continue;
|
||||
for (std::size_t f = 0; f < frameCount; ++f) {
|
||||
@@ -240,6 +264,7 @@ void VoiceEngine::render(AudioSample* left, AudioSample* right, std::size_t fram
|
||||
// iteration, same mid-block idle short-circuit) so stereo and mono share one stealing/idle
|
||||
// discipline; only the per-frame call differs (renderFrameStereo vs renderFrame).
|
||||
if (left == nullptr || right == nullptr || frameCount == 0) return;
|
||||
applyLiveToActive(); // block boundary, once — never inside the frame loop
|
||||
for (Voice& voice : voices_) {
|
||||
if (!voice.active()) continue;
|
||||
for (std::size_t f = 0; f < frameCount; ++f) {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#include <vector>
|
||||
|
||||
#include "core/audio/peaks.h"
|
||||
#include "core/instrument/engine/live_params.h"
|
||||
#include "core/instrument/engine/play_params.h"
|
||||
#include "core/instrument/engine/voice.h"
|
||||
|
||||
@@ -106,6 +107,23 @@ private:
|
||||
// one per the documented policy. Always returns a valid index (maxVoices >= 1).
|
||||
std::size_t allocateVoice();
|
||||
|
||||
// --- Live-parameter observation (live_params.h) ---
|
||||
// The ONE place the seqlock is read: at block start and at each note-on, on the audio
|
||||
// thread, never per frame. A torn or never-published read leaves the last good snapshot
|
||||
// in place rather than spinning. Returns whether a NEW generation landed.
|
||||
bool refreshLive();
|
||||
// Block-boundary refresh: pushes a newly-observed generation into every sounding voice,
|
||||
// which glides toward it. No-op when nothing changed (and when no block is attached).
|
||||
void applyLiveToActive();
|
||||
// The one restart path: start the voice, hand it the live values outright (it has nothing
|
||||
// to glide from), and stamp its age. Shared by the poly steal and both mono restarts so
|
||||
// no restart site can miss the live handoff.
|
||||
void startVoice(Voice& voice, int note, int velocity);
|
||||
|
||||
instrument::engine::LiveValues live_{};
|
||||
std::uint32_t liveGeneration_ = 0; // last generation observed; 0 = none yet
|
||||
bool haveLive_ = false;
|
||||
|
||||
// Count of active Preserve-engine voices (for the Preserve cap). Rescanned per note-on
|
||||
// (cheap: bounded by maxVoices) rather than maintained as a running tally.
|
||||
std::size_t activePreserveVoices() const;
|
||||
|
||||
@@ -97,4 +97,31 @@ 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;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
|
||||
@@ -72,6 +72,20 @@ 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.
|
||||
//
|
||||
// Deliberately NOT live, each for its own reason: 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; pitch key-track and the velocity->cutoff depth
|
||||
// feed values a voice latches at note-on by design (the pitch ratio and the velocity-curve
|
||||
// result), so making them live would retune or re-gain a note already struck; and Trigger's
|
||||
// %-length and fades resolve a play span that is a fact about the note.
|
||||
bool isLiveDeckParam(DeckParam id);
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -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` predicate — 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.
|
||||
|
||||
`liveParams_` is declared ahead of the instrument slots so it outlives every snapshot pointing at
|
||||
it. The editor's `commitLive` is the tier-3 peer of `commitAndReload` and still writes the
|
||||
parameter set, so persistence is unchanged — `getState` serializes `params_`, never a reload
|
||||
artifact, and the reload was never the persistence trigger.
|
||||
|
||||
**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
|
||||
|
||||
@@ -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 the same way — no bridge read, no WAV re-decode, no snapshot rebuild.
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -136,6 +136,23 @@ 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 {
|
||||
if (kind == DragKind::kDeckKnob) {
|
||||
// Negative ids are the processor-side sentinels (preview velocity), not parameter-set
|
||||
// controls, so they never reach the enum.
|
||||
return paramId >= 0 &&
|
||||
instrument::ui::isLiveDeckParam(static_cast<ParamControl>(paramId));
|
||||
}
|
||||
return kind == DragKind::kEnvNode && params_.play.playMode == PlayMode::Gate;
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -148,7 +148,16 @@ 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_;
|
||||
liveParams_.publish(instrument::engine::foldLive(sample.play));
|
||||
builtSampleRate_.store(sample.sampleRate, std::memory_order_relaxed);
|
||||
resolvedId = selId; // the concrete pick that resolved
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -152,6 +152,13 @@ void ReaSamplerProcessor::setInstrumentParams(const InstrumentParams& params) {
|
||||
params_ = params;
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::publishLiveParams() {
|
||||
const int rate = builtSampleRate_.load(std::memory_order_relaxed);
|
||||
if (rate <= 0) return;
|
||||
liveParams_.publish(
|
||||
instrument::engine::foldLive(resolvePlay(instrumentParams().play, rate)));
|
||||
}
|
||||
|
||||
SampleRefs ReaSamplerProcessor::sampleRefs() {
|
||||
std::lock_guard<std::mutex> lock(refsMutex_);
|
||||
return sampleRefs_;
|
||||
|
||||
@@ -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,15 @@ 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: no bridge read, no WAV re-decode, no engine
|
||||
// rebuild, no snapshot swap — the sounding note follows the knob. Persistence is
|
||||
// unaffected: getState still serializes params_, so 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.
|
||||
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 +235,16 @@ 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_;
|
||||
// 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.
|
||||
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
|
||||
|
||||
@@ -186,7 +186,40 @@ static void testBipolarKnobLawRoundTripsAndIsExactAtCentre() {
|
||||
CHECK(deckNormFromBipolar(3.0) == 1.0);
|
||||
}
|
||||
|
||||
static void testLiveRoutingCoversEveryContinuousPlaybackControl() {
|
||||
// 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. kFilterVel and kKeyTrack are the two that look
|
||||
// continuous but feed values a voice latches at note-on (the velocity-curve result and
|
||||
// the pitch ratio) — making them live would re-gain or retune a note already struck.
|
||||
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));
|
||||
|
||||
// Every id in the space is classified by one of the two lists above, so a control added
|
||||
// later cannot slip through unclassified.
|
||||
const std::size_t classified = (sizeof(live) + sizeof(reloads)) / sizeof(DeckParam);
|
||||
CHECK(classified == static_cast<std::size_t>(DeckParam::kCount));
|
||||
}
|
||||
|
||||
int main() {
|
||||
testLiveRoutingCoversEveryContinuousPlaybackControl();
|
||||
testDeckReadsPitchThenFilterThenAmpLeftToRight();
|
||||
testFilterGroupCarriesItsFiveToneControlsPlusModulation();
|
||||
testAmpGroupWidthSurvivesAGateTriggerFlip();
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
// 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
|
||||
// filter knob moves the note that is already playing, 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;
|
||||
}
|
||||
|
||||
// --- 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();
|
||||
if (i == 0) CHECK(v == 1.0); // the first frame reproduces the pre-change level exactly
|
||||
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 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 ------------------------------------------------------
|
||||
|
||||
// Renders `blocks` blocks of `blockFrames` through a one-voice engine over `sample`, applying
|
||||
// `mutate` to the published block after `changeAfter` blocks. 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.
|
||||
static Run renderWithLive(SampleData& sample, LiveParams* block, int blockFrames, int blocks,
|
||||
int changeAfter, const LiveValues* changed) {
|
||||
sample.live = block;
|
||||
if (block) block->publish(foldLive(sample.play));
|
||||
VoiceEngine engine(1, sample);
|
||||
engine.noteOn(72, 100);
|
||||
Run r;
|
||||
for (int b = 0; b < blocks; ++b) {
|
||||
if (block && changed && b == changeAfter) block->publish(*changed);
|
||||
engine.render(r.out, static_cast<std::size_t>(blockFrames));
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
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;
|
||||
s.play.filter.env.sustainLevel = 1.0;
|
||||
return s;
|
||||
}
|
||||
|
||||
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 glided rather than stepping. Measured against the signal's OWN local scale
|
||||
// frame by frame, because a resonant sweep legitimately grows the output as the corner
|
||||
// passes the tone — an absolute delta bound would flag that as a click. A step shows
|
||||
// up instead as one frame far outside the range its own neighbourhood was moving in.
|
||||
// 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.
|
||||
double immediate = 0.0;
|
||||
for (std::size_t i = 512 * 8; i < 512 * 8 + 16; ++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 * 0.4)) std::printf(" (glide: %s %f vs %f)\n", c.name,
|
||||
immediate, settled);
|
||||
CHECK(immediate <= settled * 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
static void testDrainSlotVoiceTracksTheSameBlock() {
|
||||
// Two snapshots, one block — exactly the processor's live_/draining_ shape. A note ringing
|
||||
// out of the displaced snapshot must answer the knob identically to a live one.
|
||||
SampleData liveSnapshot = filteredSine();
|
||||
SampleData drainSnapshot = filteredSine();
|
||||
LiveParams block;
|
||||
liveSnapshot.live = █
|
||||
drainSnapshot.live = █
|
||||
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);
|
||||
// Non-tautological: the shared block genuinely moved the sound, so "identical" is a claim
|
||||
// about the drain tracking, not 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);
|
||||
}
|
||||
|
||||
// --- What stays latched at note-on -------------------------------------------------------
|
||||
|
||||
static void testVelocityNoteAndPitchStayLatched() {
|
||||
// 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.
|
||||
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.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);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testStageDurationChangeHoldsPhase();
|
||||
testShortenedStageStillLandsContinuously();
|
||||
testSustainLevelChangeGlides();
|
||||
testPitchEnvelopeHoldsPhaseAndGlidesDepth();
|
||||
testCutoffMoveAcrossPrepareDoesNotStep();
|
||||
testUnmovedBlockIsByteIdenticalToNoBlockAtAll();
|
||||
testEveryLiveFilterControlMovesTheSoundingNote();
|
||||
testDrainSlotVoiceTracksTheSameBlock();
|
||||
testVelocityNoteAndPitchStayLatched();
|
||||
if (g_fail == 0) std::printf("live_delivery tests passed\n");
|
||||
return g_fail == 0 ? 0 : 1;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user