Service both VST3 parameter channels, and promote pitch key-track and Trigger length so all 44 ids issue
The SDK's own single-component sample drains inputParameterChanges in process() and implements setParamNormalized; automation was reading the GUI channel alone. The audio thread now patches a block it solely owns.
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
# src/core/instrument — pure VST3-instrument core (bake / engine / map / note / ui)
|
||||
# src/core/instrument — pure VST3-instrument core (bake / engine / map / note / param / ui)
|
||||
|
||||
## Scope
|
||||
|
||||
The ReaSampler 9000 instrument's pure, REAPER-free, VST3-free, unit-tested core, in five
|
||||
The ReaSampler 9000 instrument's pure, REAPER-free, VST3-free, unit-tested core, in six
|
||||
subdirectories:
|
||||
|
||||
- **`engine/`** — the polyphonic voice engine, the one set of play params, pitch shifting,
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
# Control mapping, SVF coefficients, morph weights, and the filter type each get their own
|
||||
# TU; VoiceFilter::process stays header-inline so the kernel still inlines at the call site.
|
||||
# The frozen control-position laws are their own target: they are the filter's PARAMETER
|
||||
# surface, and the VST3 parameter layer reports Hz/Q/drive through them. Kept separable so
|
||||
# that consumer does not take a link edge onto the per-voice kernel — the extension's link
|
||||
# graph must never be able to reach the voice DSP (root CLAUDE.md, the bake invariant).
|
||||
reasampler_pure_library(filter_params SOURCES filter_params.cpp)
|
||||
|
||||
reasampler_pure_library(filter SOURCES
|
||||
filter_params.cpp
|
||||
filter_coeffs.cpp
|
||||
filter_morph.cpp
|
||||
voice_filter.cpp)
|
||||
voice_filter.cpp
|
||||
LINK PUBLIC filter_params)
|
||||
|
||||
# Four test targets along the module's own seams so each asserts one domain. filter_tests
|
||||
# alone owns the analytic reference and the steady-state gain measurement — a forked copy of
|
||||
|
||||
@@ -5,8 +5,13 @@
|
||||
|
||||
namespace reasampler::instrument::engine {
|
||||
|
||||
LiveValues foldLive(const PlayParams& params) {
|
||||
LiveValues foldLive(const PlayParams& params, double keyTrack) {
|
||||
LiveValues v;
|
||||
v.keyTrack = keyTrack;
|
||||
// Folded here, not at the voice: Voice::start reads the block's value directly, so the
|
||||
// spline rule has to be applied on the way in or the two would answer differently.
|
||||
v.splineActive = splineActive(params);
|
||||
v.lengthFraction = effectiveLengthFraction(params);
|
||||
v.filterSettings = params.filter.settings;
|
||||
v.filterModAmount = params.filter.modAmount;
|
||||
v.filterVelAmount = params.filter.velAmount;
|
||||
|
||||
@@ -52,6 +52,19 @@ struct LiveValues {
|
||||
// ordinarily live.
|
||||
double playRate = 1.0;
|
||||
double pitchOffsetSemitones = 0.0;
|
||||
// Two more members of playRate's note-on-latched class, here for the same reason it is:
|
||||
// both resolve a fact the voice fixes at note-on (the pitch ratio, and playEnd_), so live
|
||||
// delivery would retune or re-span a note already struck. Voice::start receives them as
|
||||
// arguments; applyLive never touches either.
|
||||
double keyTrack = kKeyTrackDefault;
|
||||
// ALREADY spline-folded (effectiveLengthFraction) — a drawn contour is a pure time function
|
||||
// over the whole sample, so the stored knob is inert while one is active and the block must
|
||||
// carry what the voice will actually play, not the stored value.
|
||||
double lengthFraction = 1.0;
|
||||
// The drawn-EG state the fold above reads. A mode flip travels by reload like the contours
|
||||
// themselves, so this is not a control; it rides here only so a block-boundary write of
|
||||
// Trigger length (a host automation point) can apply the SAME fold rather than un-doing it.
|
||||
bool splineActive = false;
|
||||
};
|
||||
|
||||
// The seqlock copies the block as raw bytes, which is only defensible for a plain value type.
|
||||
@@ -59,8 +72,10 @@ 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);
|
||||
// here so there is a single site to keep in step with PlayParams. `keyTrack` is passed in
|
||||
// because it belongs to the capture/instrument scalar beside the play bundle, not to
|
||||
// PlayParams — SampleData::keyTrack at the reload, InstrumentParams::keyTrack at a live commit.
|
||||
LiveValues foldLive(const PlayParams& params, double keyTrack);
|
||||
|
||||
// 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
|
||||
@@ -96,6 +111,10 @@ public:
|
||||
seq_.store(next, std::memory_order_release); // even: complete and coherent
|
||||
}
|
||||
|
||||
// The last generation published, without copying the block — one relaxed load, so a reader
|
||||
// that only needs "has anything moved" pays nothing for asking on a block where nothing has.
|
||||
std::uint32_t generation() const { return seq_.load(std::memory_order_relaxed); }
|
||||
|
||||
// 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).
|
||||
|
||||
@@ -17,6 +17,13 @@ double masterGainMaxLinear() { return std::pow(10.0, kMasterGainMaxDb / 20.0); }
|
||||
double masterGainDbFromNorm(double norm) {
|
||||
norm = clamp01(norm);
|
||||
if (norm <= 0.0) return -std::numeric_limits<double>::infinity();
|
||||
// UNITY IS EXACT, and the argument is arithmetic rather than structural — a host's
|
||||
// reset-to-default arrives here as toPlain(defaultNormalized) and must land on 0.0 dB, not a
|
||||
// hair off it. fl(60/84) differs from 60/84 by δ ≈ 1.6e-17; 84·δ ≈ 1.33e-15 sits under the
|
||||
// half-ulp of 60 (3.55e-15), so -60 + fl(60/84)·84 rounds to exactly 60 and the sum to 0.
|
||||
// PRECONDITION: no FP contraction. Fused into a single FMA the residue survives as 1.33e-15.
|
||||
// Safe on the shipped MSVC/x64 default (no FMA without /arch:AVX2); a build that enables
|
||||
// contraction here breaks the exactness test in test_param_units, which is where it surfaces.
|
||||
return kMasterGainMinDb + norm * (kMasterGainMaxDb - kMasterGainMinDb);
|
||||
}
|
||||
|
||||
|
||||
@@ -148,6 +148,11 @@ struct FilterParams {
|
||||
// with the pitch envelope's own depth throw so the two pitch modulators speak one range.
|
||||
inline constexpr double kVelocityPitchRangeSemitones = 24.0;
|
||||
|
||||
// Standard 12-tone-ET tracking, and the ONE home for that number: the capture's own scalar, the
|
||||
// instrument's stored scalar and the live block all default from here, so a blob predating the
|
||||
// field and a block published before the first note can never disagree about it.
|
||||
inline constexpr double kKeyTrackDefault = 1.0;
|
||||
|
||||
// Bundle a voice reads at start(). Defaults reproduce the bare engine (Gate, hold-0 AHDSR,
|
||||
// Varispeed, pitch envelope off, filter off, no velocity->pitch) — core regression tests rely
|
||||
// on this; the Preserve product default is layered on at (de)serialization, see
|
||||
@@ -267,7 +272,7 @@ struct SampleData {
|
||||
// How far keyboard pitch tracks the root: 1.0 = standard 12-tone-ET (default); 0.0 = no
|
||||
// tracking (every key plays root pitch); 2.0 = double-rate. Scales the (note-root) semitone
|
||||
// offset in keyTrackedRatio; rides both repitch engines via the voice's baseRatio_.
|
||||
double keyTrack = 1.0;
|
||||
double keyTrack = kKeyTrackDefault;
|
||||
|
||||
// Maps note-on velocity (0..127) to the voice's amp gain, eval'd once in Voice::start
|
||||
// (never per frame). Default flat y=1 — every velocity plays at unity.
|
||||
|
||||
@@ -19,7 +19,7 @@ void Voice::presizePreserveShifters(std::int64_t windowFrames) {
|
||||
}
|
||||
|
||||
void Voice::start(int note, int velocity, const SampleData& sample, bool declickTakeover,
|
||||
double stretchRate) {
|
||||
double stretchRate, double keyTrack, double lengthFraction) {
|
||||
// Before any state reset, record the pre-cut reference (last rendered output) and mark
|
||||
// the compensation pending iff this start is a takeover/steal of a sounding voice and the
|
||||
// caller opted in. The ramp is seeded on the first frame rendered after the restart, from
|
||||
@@ -72,6 +72,7 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
|
||||
// the control outright — the predicate is spelled the same way advanceFrame spells it.
|
||||
preserveRead_ = (pitchEngine_ == PitchEngine::Preserve) && shiftL_.configured();
|
||||
rateRatio_ = preserveRead_ ? 1.0 : stretchRate_;
|
||||
keyTrack_ = (keyTrack < 0.0) ? sample.keyTrack : keyTrack;
|
||||
recomputeBaseRatio();
|
||||
// pitchOffsetRatio_ is a power of 2 and never zero, so this inverse is well-defined — and at
|
||||
// Pitch 0 it is a division by exactly 1.0.
|
||||
@@ -120,10 +121,9 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
|
||||
} else {
|
||||
// Trigger: play [start, playEnd) where playEnd = start + round(frac*(frames-start)) —
|
||||
// map/trigger_seam.h's formula, evaluated inline because the engine does not depend on
|
||||
// map/. The spline fold is effectiveLengthFraction (play_params.h); a second copy of it
|
||||
// here is what let a stored-but-inert %-knob shorten the bake while the voice played
|
||||
// the whole take.
|
||||
double frac = effectiveLengthFraction(p);
|
||||
// map/. The caller's value is ALREADY spline-folded (foldLive does it); the snapshot
|
||||
// fallback folds here, because a stored-but-inert %-knob must not shorten the span.
|
||||
double frac = (lengthFraction < 0.0) ? effectiveLengthFraction(p) : lengthFraction;
|
||||
if (!(frac > 0.0)) frac = 0.0; // %=0 (or a corrupt NaN) -> finishes immediately
|
||||
if (frac > 1.0) frac = 1.0;
|
||||
std::int64_t playLen = static_cast<std::int64_t>(
|
||||
|
||||
@@ -45,6 +45,9 @@ inline double pitchRatio(int note, int rootNote) {
|
||||
// ((note-root)*1.0 is exact in IEEE-754 for an integer-valued double, feeding the same
|
||||
// std::pow call); 0.0 means every key plays the root pitch; 2.0 doubles the tracking rate.
|
||||
// At the root note the offset is 0 regardless of keyTrack.
|
||||
// "Not supplied" for Voice::start's two snapshot-defaulted note-on latches; see start().
|
||||
inline constexpr double kLatchFromSnapshot = -1.0;
|
||||
|
||||
inline double keyTrackedRatio(int note, int rootNote, double keyTrack) {
|
||||
const double semis = static_cast<double>(note - rootNote) * keyTrack;
|
||||
return std::pow(2.0, semis / 12.0);
|
||||
@@ -124,8 +127,16 @@ public:
|
||||
// to consult gets exactly that; VoiceEngine::startVoice is what resolves the real value —
|
||||
// sample.play.playRate is NOT read here, because the published block outranks the snapshot's
|
||||
// possibly-stale copy of it.
|
||||
//
|
||||
// `keyTrack` and `lengthFraction` are the other two members of stretchRate's note-on-latched
|
||||
// class and arrive the same way, for the same structural reason. Negative = not supplied,
|
||||
// which reads the snapshot's own value (sample.keyTrack, effectiveLengthFraction(play)) —
|
||||
// both are non-negative by domain, so the sentinel can never collide with a real one.
|
||||
// VoiceEngine::startVoice always supplies them, resolved from the published block when there
|
||||
// is one; the sentinel is for a caller that has no block to consult.
|
||||
void start(int note, int velocity, const SampleData& sample, bool declickTakeover = false,
|
||||
double stretchRate = 1.0);
|
||||
double stretchRate = 1.0, double keyTrack = kLatchFromSnapshot,
|
||||
double lengthFraction = kLatchFromSnapshot);
|
||||
|
||||
// Mono legato takeover: re-pitch this active voice to `note` without touching the
|
||||
// amplitude envelope, read position, or shifter state — pitch moves, no re-attack. Both
|
||||
@@ -201,7 +212,7 @@ private:
|
||||
// shifter's transpose. Cold: note-on, legato retune, and a live block, never per frame.
|
||||
void recomputeBaseRatio() {
|
||||
if (sample_ == nullptr) return;
|
||||
baseRatio_ = keyTrackedRatio(note_, sample_->rootNote, sample_->keyTrack) *
|
||||
baseRatio_ = keyTrackedRatio(note_, sample_->rootNote, keyTrack_) *
|
||||
velPitchRatio_ * pitchOffsetRatio_ * rateRatio_;
|
||||
}
|
||||
|
||||
@@ -710,6 +721,10 @@ private:
|
||||
double velPitchRatio_ = 1.0; // the velocity->pitch factor alone; retune re-applies it
|
||||
double pitchOffsetRatio_ = 1.0; // the Pitch knob's factor — LIVE, re-applied by applyLive
|
||||
double rateRatio_ = 1.0; // Rate's factor of the read increment; start() owns when it is 1
|
||||
// Key-track, LATCHED at note-on beside the rate. Held here rather than re-read off the
|
||||
// snapshot so a legato retune and a live block re-apply the note's own value; a published
|
||||
// move reaches the next note only.
|
||||
double keyTrack_ = kKeyTrackDefault;
|
||||
// Whether this note is ACTUALLY taking the Preserve read — a Preserve voice whose shifters
|
||||
// were never sized falls back to the varispeed one, and the two domains differ. Latched at
|
||||
// note-on beside rateRatio_, which start() resolves from the same predicate.
|
||||
|
||||
@@ -56,9 +56,14 @@ void VoiceEngine::startVoice(Voice& voice, int note, int velocity) {
|
||||
refreshLive();
|
||||
// THE read of the note-on-latched commit class, and the only one: a published block outranks
|
||||
// the snapshot's own copy (a live edit deliberately leaves that stale), and applyLive below
|
||||
// never touches the rate — so a Rate move reaches the next note and no sounding one.
|
||||
// touches none of these three — so a move reaches the next note and no sounding one.
|
||||
const double rate = haveLive_ ? live_.playRate : sample_.play.playRate;
|
||||
voice.start(note, velocity, sample_, /*declickTakeover=*/takeoverDeclick_, rate);
|
||||
const double keyTrack = haveLive_ ? live_.keyTrack : sample_.keyTrack;
|
||||
// Already spline-folded in the block; the snapshot branch folds here so the two agree.
|
||||
const double lengthFraction =
|
||||
haveLive_ ? live_.lengthFraction : effectiveLengthFraction(sample_.play);
|
||||
voice.start(note, velocity, sample_, /*declickTakeover=*/takeoverDeclick_, rate, keyTrack,
|
||||
lengthFraction);
|
||||
if (haveLive_) voice.applyLive(live_, /*snap=*/true);
|
||||
voice.setStartOrder(nextStartOrder_++);
|
||||
}
|
||||
|
||||
@@ -5,12 +5,25 @@
|
||||
// not link the bank model and the WAV codec to reach one value struct. `resolvePlay`, which
|
||||
// turns them into the engine's frame domain, stays in sample_map with the rest of the mapping.
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
#include "core/instrument/engine/play_params.h" // PlayMode / TriggerParams / SplineEnv / …
|
||||
|
||||
namespace reasampler::instrument::map {
|
||||
|
||||
using instrument::engine::VelocityCurve;
|
||||
|
||||
// THE seconds -> frames fold, and the one home for its rounding: resolvePlay resolves the whole
|
||||
// bundle through it, and the audio thread's live patch (param/param_live) resolves one stage
|
||||
// time through it, so a stage time can never land on a different frame depending on the writer.
|
||||
// A non-positive rate yields 0 rather than inventing one; a negative time floors at 0.
|
||||
inline std::int64_t secondsToFrames(double seconds, double sampleRate) {
|
||||
if (!(sampleRate > 0.0)) return 0;
|
||||
double f = seconds * sampleRate;
|
||||
if (!(f > 0.0)) return 0; // also catches NaN
|
||||
return static_cast<std::int64_t>(f + 0.5);
|
||||
}
|
||||
|
||||
// Daniel's standing ruling: no hardcoded sample rate anywhere in the program. The
|
||||
// instrument stores/edits wall-clock performance times (AHDSR A/H/D/R, pitch-env A/D) as
|
||||
// SECONDS, rate-free; the engine receives FRAMES resolved from the LIVE sample rate at
|
||||
|
||||
@@ -215,11 +215,7 @@ PlayParams resolvePlay(const PlaySeconds& stored, int sampleRate) {
|
||||
// carries through untouched, already a fraction.
|
||||
assert(sampleRate > 0 && "resolvePlay: sampleRate must be > 0 (programming error)");
|
||||
const double sr = sampleRate > 0 ? static_cast<double>(sampleRate) : 1.0; // 1.0 avoids div-by-zero; assert fires first
|
||||
const auto secToFrames = [sr](double sec) {
|
||||
double f = sec * sr;
|
||||
if (f < 0.0) f = 0.0;
|
||||
return static_cast<std::int64_t>(f + 0.5);
|
||||
};
|
||||
const auto secToFrames = [sr](double sec) { return secondsToFrames(sec, sr); };
|
||||
// The one seconds->frames fold for a stored AHD; the fraction and the curves are rate-free.
|
||||
const auto resolveAhd = [&secToFrames](const AhdSeconds& s) {
|
||||
AhdParams a;
|
||||
|
||||
@@ -180,7 +180,7 @@ struct InstrumentParams {
|
||||
// exactly 1.0, so already-saved instances are bit-identical. 0.0 = no tracking (every
|
||||
// key plays root pitch); 2.0 = double. Applied in keyTrackedRatio inside both repitch
|
||||
// engines.
|
||||
double keyTrack = 1.0;
|
||||
double keyTrack = kKeyTrackDefault;
|
||||
|
||||
// Velocity->amp transfer curve: maps note-on MIDI velocity (0..127) to voice amp gain,
|
||||
// replacing the old fixed linear velocity/127. Default = flat y=1 (Daniel-approved):
|
||||
@@ -215,7 +215,7 @@ struct InstrumentParams {
|
||||
struct ResolvedCapture {
|
||||
std::string relativePath; // project-relative; the shell resolves + decodes it
|
||||
int rootNote = 60; // effective: override, else bank intrinsic, else 60
|
||||
double keyTrack = 1.0;
|
||||
double keyTrack = kKeyTrackDefault;
|
||||
VelocityCurve velocityCurve = VelocityCurve::flat();
|
||||
SampleLoop loop; // effective: loopOverride, else bank intrinsic
|
||||
std::int64_t loopCrossfadeFrames = 0; // instrument-owned; no bank intrinsic to beat
|
||||
|
||||
@@ -11,6 +11,12 @@ these onto `Steinberg::Vst::Parameter`; it decides nothing.
|
||||
A sixth peer of `engine/` / `map/` / `note/` / `bake/` / `ui/`, and it sits ABOVE `ui/`: the
|
||||
parameter list is a function of `deckParamCommit` and the value binding, never the reverse.
|
||||
|
||||
**Where an exposed control's value lives is `valueHomeFor`'s answer, and the exposed set is
|
||||
asserted against it.** Two controls sit beside the parameter set rather than in it — master gain
|
||||
(the processor's atomic) and pitch key-track (`InstrumentParams::keyTrack`) — and a promotion
|
||||
whose control has no home would no-op silently in both directions on the host path with nothing
|
||||
to catch it at compile time. That is exactly what happened to id 1000 before the guard existed.
|
||||
|
||||
## Invariants
|
||||
|
||||
### The id table is FOREVER-FROZEN
|
||||
@@ -57,6 +63,13 @@ no longer exist.
|
||||
- `param_units` — `UnitKind`, `unitStringFor`, `plainRangeFor`, the `toPlain` / `toNormalized`
|
||||
pair, and the defaults read off a default-constructed `PlaySeconds`.
|
||||
- `param_format` — the eight formatters and the digits parser behind `getParamValueByString`.
|
||||
- `param_live` — the AUDIO-THREAD half: one exposed control patched into the live block in
|
||||
place, allocation-free and lock-free, for the host's `IParameterChanges` queue. It exists
|
||||
because the model layer cannot run there (`PlaySeconds` carries velocity curves and spline
|
||||
contours, so `resolvePlay` allocates) while the queue is delivered there. Every law is called —
|
||||
`ui::storedFromNorm` and `map::secondsToFrames` are the same two the model path uses; what is
|
||||
new is the ROUTING, and that is pinned by an exhaustive equivalence test against the model path
|
||||
over every exposed control rather than by two tables that happen to agree.
|
||||
|
||||
## Gotchas
|
||||
|
||||
@@ -68,18 +81,33 @@ no longer exist.
|
||||
- **Round-trip exactness at arbitrary values is NOT a property here and must not be asserted.**
|
||||
No log map satisfies `toNormalized(toPlain(n)) == n` in double, and demanding it would rule
|
||||
out the taper the range needs. Exactness is required at the defaults; monotonicity everywhere.
|
||||
- **A curve exponent inside the knob detent but not exactly neutral reads `1.00` to the host.**
|
||||
The detent lives in `curve_law`'s norm↔exponent map and the host's only handle is the norm, so
|
||||
the host cannot see an off-detent near-neutral exponent — reachable only by an overlay knot
|
||||
drag, which writes through `curveFromLevelAt` rather than the knob law. The editor's own label
|
||||
deliberately reads the stored field directly and still shows the true value; that divergence is
|
||||
structural to VST3, not a formatter defect.
|
||||
- **A curve exponent inside the knob detent but not exactly neutral is NEUTRALIZED by any host
|
||||
touch — a VALUE consequence, not a display one.** The detent lives in `curve_law`'s
|
||||
norm↔exponent map and the host's only handle is the norm, so the host reads such an exponent
|
||||
back as `1.00` (the editor's own label reads the stored field and still shows the true value).
|
||||
The sharp half is the WRITE: a host write of that norm reaches `ui::setDeckParam` →
|
||||
`storedFromNorm` → `util::curveFromKnobNorm`, whose ±0.01 detent rewrites the stored exponent
|
||||
to exactly `1.0`. So an off-detent near-neutral exponent set by an overlay knot drag is
|
||||
silently flattened by any host touch or lane pass over that parameter.
|
||||
**Assessed and ACCEPTED, not merely documented:** the alternative is to widen the exposed
|
||||
parameter's law so the detent band is addressable, and §6.3 freezes that law on the first
|
||||
shipped build — a permanent change to twelve parameters' normalization, to preserve a
|
||||
difference the user cannot see on the knob (the detent exists precisely because a drag cannot
|
||||
land on the identity reliably) and cannot hear (the band is ±0.047 of the exponent). Removing
|
||||
the detent from the WRITE path alone would leave the knob unable to reach the identity, which
|
||||
is the defect it was added for. The residual is confined to knot-drawn near-neutral curves.
|
||||
- **Master gain's plain value at norm 0 is `-inf`**, which is outside the declared −60…+24 range
|
||||
on purpose — norm 0 is true silence, not the floor. The formatter prints `-inf` there.
|
||||
- **The filter's four store their position as a `float`, so a not-yet-stored norm can display
|
||||
one digit differently.** A host previewing a value it has sent but that has not round-tripped
|
||||
through the stored float differs from the editor by up to a float ulp; at a value landing
|
||||
exactly on a display rounding boundary that is worth one integer percent on morph. Both
|
||||
surfaces read the MODEL in every settled state, so this is a transient of the write itself,
|
||||
not a standing divergence — `test_param_format` holds those four to the plain value rather
|
||||
than to the string for exactly this reason.
|
||||
- **MORPH ALONE can display one digit differently from a not-yet-stored norm.** The filter's four
|
||||
store their position as a `float`, but cutoff, Q and drive cast the incoming norm to `float`
|
||||
*inside* `toPlain`, so `toPlain(n)` and `toPlain(double(float(n)))` are bit-identical and those
|
||||
three are held to digit-for-digit string equality like everything else. Morph's path is
|
||||
full-double (`clamp01(n) * 100`), so the float the model stores and the double the host holds
|
||||
are genuinely different inputs — worth one integer percent at a value landing on a display
|
||||
rounding boundary. Both surfaces read the MODEL in every settled state, so it is a transient of
|
||||
the write itself, not a standing divergence; `test_param_format` holds morph alone to the plain
|
||||
value rather than to the string.
|
||||
- **A host write the MODEL clamps is not a settled state either.** Trigger length's stored
|
||||
domain is `(0,1]`, so a host norm of 0 comes back as 0.01. `setParamNormalized` caches what the
|
||||
model took, so the host never holds the rejected value — the sweep skips the clamped steps for
|
||||
that reason rather than loosening its comparison.
|
||||
|
||||
@@ -5,13 +5,28 @@ reasampler_test(param_id LINK param_id)
|
||||
|
||||
# The norm <-> plain layer. deck_values carries the tapers' full scales and the two field
|
||||
# resolvers the defaults are read through; filter_params and master_gain are the frozen laws the
|
||||
# filter's four and the gain report through, CALLED rather than restated.
|
||||
# filter's four and the gain report through, CALLED rather than restated. filter_params rather
|
||||
# than the whole `filter` target: this is the parameter surface, and a link edge from it onto the
|
||||
# per-voice filter KERNEL would put the voice DSP in reach of any future extension-side consumer
|
||||
# of param_format — which root CLAUDE.md's bake invariant forbids.
|
||||
reasampler_pure_library(param_units
|
||||
SOURCES param_units.cpp
|
||||
LINK PUBLIC deck_values param_taper curve_law master_gain filter)
|
||||
reasampler_test(param_units LINK param_units param_id)
|
||||
LINK PUBLIC deck_values param_taper curve_law master_gain filter_params)
|
||||
# sample_map for the test alone: the host-vs-editor default agreement reads the two instance
|
||||
# scalars where they LIVE, and one of them is a field of InstrumentParams.
|
||||
reasampler_test(param_units LINK param_units param_id sample_map)
|
||||
|
||||
reasampler_pure_library(param_format SOURCES param_format.cpp LINK PUBLIC param_units)
|
||||
# param_id is linked for the test only: the one-formatter-two-consumers assertion sweeps the
|
||||
# exposed set, which is identity's answer rather than this module's.
|
||||
reasampler_test(param_format LINK param_format param_id)
|
||||
# param_id and sample_map are linked for the test only: the one-formatter-two-consumers assertion
|
||||
# sweeps the exposed set (identity's answer, not this module's) and reads pitch key-track where it
|
||||
# lives, on InstrumentParams.
|
||||
reasampler_test(param_format LINK param_format param_id sample_map)
|
||||
|
||||
# The audio thread's half: the live block plus the two laws it patches through. No engine —
|
||||
# the block is a value, not a thing the voice owns.
|
||||
reasampler_pure_library(param_live
|
||||
SOURCES param_live.cpp
|
||||
LINK PUBLIC deck_values live_params)
|
||||
# sample_map for the test alone: the equivalence assertion drives the MODEL path
|
||||
# (setDeckParam -> resolvePlay -> foldLive) as its reference.
|
||||
reasampler_test(param_live LINK param_live param_id param_units sample_map)
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// param_live.cpp — see param_live.h. Three field resolvers plus one dispatch; every law is
|
||||
// called, none is restated.
|
||||
|
||||
#include "core/instrument/param/param_live.h"
|
||||
|
||||
#include "core/instrument/map/play_seconds.h" // secondsToFrames (resolvePlay's own fold)
|
||||
#include "core/instrument/ui/deck_values.h" // storedFromNorm (setDeckParam's own map)
|
||||
|
||||
namespace reasampler::instrument::param {
|
||||
|
||||
namespace {
|
||||
|
||||
using engine::LiveValues;
|
||||
|
||||
// The block member a control names, in the same shape deck_values' two field resolvers take:
|
||||
// LOCATION only, no law. Null for a control the block does not carry.
|
||||
std::int64_t* frameField(LiveValues& v, DeckParam deck) {
|
||||
switch (deck) {
|
||||
case DeckParam::kAttack: return &v.adsr.attackFrames;
|
||||
case DeckParam::kHold: return &v.adsr.holdFrames;
|
||||
case DeckParam::kDecay: return &v.adsr.decayFrames;
|
||||
case DeckParam::kRelease: return &v.adsr.releaseFrames;
|
||||
case DeckParam::kTrigAttack: return &v.ampAhd.attackFrames;
|
||||
case DeckParam::kTrigDecay: return &v.ampAhd.decayFrames;
|
||||
case DeckParam::kPitchEnvAttack: return &v.pitchEnv.shape.attackFrames;
|
||||
case DeckParam::kPitchEnvDecay: return &v.pitchEnv.shape.decayFrames;
|
||||
case DeckParam::kFilterEnvAttack: return &v.filterEnv.attackFrames;
|
||||
case DeckParam::kFilterEnvHold: return &v.filterEnv.holdFrames;
|
||||
case DeckParam::kFilterEnvDecay: return &v.filterEnv.decayFrames;
|
||||
case DeckParam::kFilterEnvRelease: return &v.filterEnv.releaseFrames;
|
||||
case DeckParam::kFilterTrigAttack: return &v.filterAhd.attackFrames;
|
||||
case DeckParam::kFilterTrigDecay: return &v.filterAhd.decayFrames;
|
||||
default: return nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
// The filter's four, which store their normalized position as float in the block exactly as the
|
||||
// parameter set stores it.
|
||||
float* normField(LiveValues& v, DeckParam deck) {
|
||||
switch (deck) {
|
||||
case DeckParam::kFilterMorph: return &v.filterSettings.morphNorm;
|
||||
case DeckParam::kFilterCutoff: return &v.filterSettings.cutoffNorm;
|
||||
case DeckParam::kFilterQ: return &v.filterSettings.resonanceNorm;
|
||||
case DeckParam::kFilterDrive: return &v.filterSettings.driveNorm;
|
||||
default: return nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
double* doubleField(LiveValues& v, DeckParam deck) {
|
||||
switch (deck) {
|
||||
case DeckParam::kSustain: return &v.adsr.sustainLevel;
|
||||
case DeckParam::kAttackCurve: return &v.adsr.attackCurve;
|
||||
case DeckParam::kDecayCurve: return &v.adsr.decayCurve;
|
||||
case DeckParam::kReleaseCurve: return &v.adsr.releaseCurve;
|
||||
case DeckParam::kTrigHold: return &v.ampAhd.holdFraction;
|
||||
case DeckParam::kTrigAttackCurve: return &v.ampAhd.attackCurve;
|
||||
case DeckParam::kTrigDecayCurve: return &v.ampAhd.decayCurve;
|
||||
case DeckParam::kPitchEnvHold: return &v.pitchEnv.shape.holdFraction;
|
||||
case DeckParam::kPitchEnvAttackCurve: return &v.pitchEnv.shape.attackCurve;
|
||||
case DeckParam::kPitchEnvDecayCurve: return &v.pitchEnv.shape.decayCurve;
|
||||
case DeckParam::kPitchEnvDepth: return &v.pitchEnv.peakSemitones;
|
||||
case DeckParam::kFilterEnvSustain: return &v.filterEnv.sustainLevel;
|
||||
case DeckParam::kFilterEnvAttackCurve: return &v.filterEnv.attackCurve;
|
||||
case DeckParam::kFilterEnvDecayCurve: return &v.filterEnv.decayCurve;
|
||||
case DeckParam::kFilterEnvReleaseCurve: return &v.filterEnv.releaseCurve;
|
||||
case DeckParam::kFilterTrigHold: return &v.filterAhd.holdFraction;
|
||||
case DeckParam::kFilterTrigAttackCurve: return &v.filterAhd.attackCurve;
|
||||
case DeckParam::kFilterTrigDecayCurve: return &v.filterAhd.decayCurve;
|
||||
case DeckParam::kFilterModAmt: return &v.filterModAmount;
|
||||
case DeckParam::kFilterVel: return &v.filterVelAmount;
|
||||
case DeckParam::kFilterKeyTrack: return &v.filterKeyTrack;
|
||||
case DeckParam::kRate: return &v.playRate;
|
||||
case DeckParam::kPitch: return &v.pitchOffsetSemitones;
|
||||
case DeckParam::kKeyTrack: return &v.keyTrack;
|
||||
default: return nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool applyLiveParam(LiveValues& block, DeckParam deck, double normalized, int sampleRate) {
|
||||
// Trigger length is the one control the block does not carry verbatim: what it publishes is
|
||||
// the SPLINE-FOLDED fraction, so a write while a contour is active must be inert here for
|
||||
// the same reason the knob is inert in the editor.
|
||||
if (deck == DeckParam::kTrigLength) {
|
||||
if (!block.splineActive) block.lengthFraction = ui::storedFromNorm(deck, normalized);
|
||||
return true;
|
||||
}
|
||||
if (std::int64_t* f = frameField(block, deck)) {
|
||||
*f = map::secondsToFrames(ui::storedFromNorm(deck, normalized),
|
||||
static_cast<double>(sampleRate));
|
||||
return true;
|
||||
}
|
||||
if (float* f = normField(block, deck)) {
|
||||
*f = static_cast<float>(ui::storedFromNorm(deck, normalized));
|
||||
return true;
|
||||
}
|
||||
if (double* f = doubleField(block, deck)) {
|
||||
*f = ui::storedFromNorm(deck, normalized);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::param
|
||||
@@ -0,0 +1,29 @@
|
||||
// param_live.h — the AUDIO-THREAD half of a host parameter write: one exposed control patched
|
||||
// into the live block, in place, with no allocation and no lock. It exists because the model
|
||||
// layer cannot run on the audio thread (PlaySeconds carries velocity curves and spline contours,
|
||||
// so resolvePlay allocates), while `IParameterChanges` is delivered there.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "core/instrument/engine/live_params.h"
|
||||
#include "core/instrument/ui/deck_groups.h" // DeckParam
|
||||
|
||||
namespace reasampler::instrument::param {
|
||||
|
||||
using ui::DeckParam;
|
||||
|
||||
// Writes `normalized` for `deck` into `block`. RT-SAFE: no allocation, no lock, no transcendental
|
||||
// beyond the taper's own. Returns false for a control this block does not carry — master gain,
|
||||
// which reaches the audio as the processor's own atomic, and anything unexposed.
|
||||
//
|
||||
// The value laws are NOT restated here: `ui::storedFromNorm` is the same norm -> stored map
|
||||
// `setDeckParam` writes with, and `map::secondsToFrames` the same fold `resolvePlay` uses. What
|
||||
// IS new is the routing — which member of the block a control names — and that is pinned by an
|
||||
// exhaustive equivalence test against the model path over every exposed control, rather than by
|
||||
// two tables that happen to agree.
|
||||
//
|
||||
// `sampleRate` is the rate the loaded capture was BUILT at (the processor's builtSampleRate_),
|
||||
// so a patched stage time lands on exactly the frames the build would have resolved.
|
||||
bool applyLiveParam(engine::LiveValues& block, DeckParam deck, double normalized, int sampleRate);
|
||||
|
||||
} // namespace reasampler::instrument::param
|
||||
@@ -66,10 +66,49 @@ UnitKind unitKindFor(DeckParam deck) {
|
||||
return UnitKind::Decibels;
|
||||
case DeckParam::kFilterCutoff:
|
||||
return UnitKind::Hertz;
|
||||
default:
|
||||
// The twelve curve exponents, Q and drive. Everything else has no row at all.
|
||||
// The twelve curve exponents and the filter's two dimensionless tone controls. Listed
|
||||
// rather than defaulted, and everything with no parameter row at all is listed with
|
||||
// them: a `default:` here would let a control promoted later inherit Dimensionless
|
||||
// silently, and §6.3 freezes an exposed parameter's normalization on the first shipped
|
||||
// build — so the wrong answer would be permanent rather than correctable.
|
||||
case DeckParam::kFilterQ:
|
||||
case DeckParam::kFilterDrive:
|
||||
case DeckParam::kAttackCurve:
|
||||
case DeckParam::kDecayCurve:
|
||||
case DeckParam::kReleaseCurve:
|
||||
case DeckParam::kTrigAttackCurve:
|
||||
case DeckParam::kTrigDecayCurve:
|
||||
case DeckParam::kPitchEnvAttackCurve:
|
||||
case DeckParam::kPitchEnvDecayCurve:
|
||||
case DeckParam::kFilterEnvAttackCurve:
|
||||
case DeckParam::kFilterEnvDecayCurve:
|
||||
case DeckParam::kFilterEnvReleaseCurve:
|
||||
case DeckParam::kFilterTrigAttackCurve:
|
||||
case DeckParam::kFilterTrigDecayCurve:
|
||||
case DeckParam::kPlayMode:
|
||||
case DeckParam::kPitchEngine:
|
||||
case DeckParam::kPitchEnvEnable:
|
||||
case DeckParam::kFilterEnable:
|
||||
case DeckParam::kFilterLaw:
|
||||
case DeckParam::kAmpVelCurve:
|
||||
case DeckParam::kPitchVelCurve:
|
||||
case DeckParam::kFilterVelCurve:
|
||||
case DeckParam::kAmpEnvSelect:
|
||||
case DeckParam::kPitchEnvSelect:
|
||||
case DeckParam::kFilterEnvSelect:
|
||||
case DeckParam::kAmpEnvMode:
|
||||
case DeckParam::kPitchEnvMode:
|
||||
case DeckParam::kFilterEnvMode:
|
||||
case DeckParam::kVoiceCount:
|
||||
case DeckParam::kVoiceMode:
|
||||
case DeckParam::kMonoTrigger:
|
||||
case DeckParam::kLimiterEnable:
|
||||
case DeckParam::kMasterMeter:
|
||||
case DeckParam::kMasterGr:
|
||||
case DeckParam::kCount:
|
||||
return UnitKind::Dimensionless;
|
||||
}
|
||||
return UnitKind::Dimensionless; // unreachable for a valid enumerator; silences a warning.
|
||||
}
|
||||
|
||||
const char* unitStringFor(DeckParam deck) {
|
||||
@@ -192,9 +231,18 @@ double toNormalized(DeckParam deck, double plain) {
|
||||
return plain;
|
||||
}
|
||||
|
||||
bool storesNormalized(DeckParam deck) {
|
||||
ValueHome valueHomeFor(DeckParam deck) {
|
||||
PlaySeconds defaults;
|
||||
return ui::deckFloatField(deck, defaults) != nullptr;
|
||||
if (ui::deckFloatField(deck, defaults)) return ValueHome::ParamSetNorm;
|
||||
if (ui::deckDoubleField(deck, defaults)) return ValueHome::ParamSet;
|
||||
if (deck == DeckParam::kMasterGain || deck == DeckParam::kKeyTrack) {
|
||||
return ValueHome::InstanceScalar;
|
||||
}
|
||||
return ValueHome::None;
|
||||
}
|
||||
|
||||
bool storesNormalized(DeckParam deck) {
|
||||
return valueHomeFor(deck) == ValueHome::ParamSetNorm;
|
||||
}
|
||||
|
||||
double defaultPlain(DeckParam deck) {
|
||||
@@ -204,10 +252,17 @@ double defaultPlain(DeckParam deck) {
|
||||
if (const float* stored = ui::deckFloatField(deck, defaults)) {
|
||||
return toPlain(deck, static_cast<double>(*stored));
|
||||
}
|
||||
// The two instance scalars, whose default is not a field of PlaySeconds.
|
||||
if (deck == DeckParam::kMasterGain) return 0.0; // unity, and the sharpest exactness case
|
||||
if (deck == DeckParam::kKeyTrack) {
|
||||
return kKeyTrackDefault * kPercentFullScale;
|
||||
}
|
||||
const double* field = ui::deckDoubleField(deck, defaults);
|
||||
if (!field) return 0.0;
|
||||
switch (unitKindFor(deck)) {
|
||||
// Time converts seconds -> ms here and ms -> seconds in toNormalized, so its exactness
|
||||
// additionally rests on x*1000/1000 == x — param_taper guarantees its quantum in SECONDS,
|
||||
// not in ms. It holds for today's three Time defaults; a new one is a case to re-check.
|
||||
case UnitKind::Time: return *field * 1000.0; // stored seconds
|
||||
case UnitKind::PercentUnipolar: return *field * kPercentFullScale;
|
||||
case UnitKind::PercentKeyTrack: return *field * kPercentFullScale; // stored 0..2
|
||||
|
||||
@@ -48,6 +48,17 @@ PlainRange plainRangeFor(DeckParam deck);
|
||||
double toPlain(DeckParam deck, double normalized);
|
||||
double toNormalized(DeckParam deck, double plain);
|
||||
|
||||
// WHERE a control's value actually lives. The host's read and write paths branch on this, and
|
||||
// the exposed set is asserted against it: a control promoted into the list with no home would
|
||||
// otherwise no-op silently in BOTH directions, with nothing to catch it at compile time.
|
||||
enum class ValueHome {
|
||||
None, // not a scalar control at all — a toggle, a radio, a curve-popup cell
|
||||
ParamSetNorm, // the filter's four: the stored double IS the normalized position
|
||||
ParamSet, // every other knob the parameter set carries
|
||||
InstanceScalar, // beside the parameter set: master gain, and the pitch key-track scalar
|
||||
};
|
||||
ValueHome valueHomeFor(DeckParam deck);
|
||||
|
||||
// The filter's four tone controls STORE their normalized position (payload v9), so their default
|
||||
// normalized value is that stored double verbatim and no taper participates in a host's
|
||||
// reset-to-default. Reporting Hz / Q / drive depth for them means CALLING their frozen laws, not
|
||||
|
||||
@@ -201,8 +201,10 @@ DeckParam curveParamFor(DeckParam knob) {
|
||||
|
||||
LiveCommit deckParamCommit(DeckParam id) {
|
||||
switch (id) {
|
||||
// The one note-on-latched control; the header owns why.
|
||||
// The note-on-latched controls; the header owns why each one latches.
|
||||
case DeckParam::kRate:
|
||||
case DeckParam::kKeyTrack:
|
||||
case DeckParam::kTrigLength:
|
||||
return LiveCommit::NoteOnLatched;
|
||||
// Live by the tier's own definition — one atomic store the audio thread picks up at the
|
||||
// next block, no bridge read and no re-decode. It reaches the audio beside the live
|
||||
@@ -256,9 +258,7 @@ LiveCommit deckParamCommit(DeckParam id) {
|
||||
// to non-live. Reasons live in the header.
|
||||
case DeckParam::kPlayMode:
|
||||
case DeckParam::kPitchEngine:
|
||||
case DeckParam::kTrigLength:
|
||||
case DeckParam::kPitchEnvEnable:
|
||||
case DeckParam::kKeyTrack:
|
||||
case DeckParam::kFilterEnable:
|
||||
case DeckParam::kAmpVelCurve:
|
||||
case DeckParam::kPitchVelCurve:
|
||||
|
||||
@@ -89,6 +89,49 @@ double deckParamNorm(DeckParam id, const PlaySeconds& play) {
|
||||
}
|
||||
}
|
||||
|
||||
double storedFromNorm(DeckParam id, double norm) {
|
||||
switch (id) {
|
||||
// The filter's four STORE their normalized position (payload v9), so the identity IS
|
||||
// their law — deckFloatField's four, and the reason it is a separate resolver.
|
||||
case DeckParam::kFilterMorph:
|
||||
case DeckParam::kFilterCutoff:
|
||||
case DeckParam::kFilterQ:
|
||||
case DeckParam::kFilterDrive:
|
||||
return clamp01(norm);
|
||||
case DeckParam::kRate:
|
||||
return rateRatioFromNorm(norm, kRateMinRatio, kRateMaxRatio);
|
||||
case DeckParam::kPitch:
|
||||
case DeckParam::kPitchEnvDepth:
|
||||
return depthSemitonesFromNorm(norm, kPitchDepthMaxSemis);
|
||||
case DeckParam::kFilterModAmt:
|
||||
case DeckParam::kFilterVel:
|
||||
return deckBipolarFromNorm(norm);
|
||||
case DeckParam::kKeyTrack:
|
||||
case DeckParam::kFilterKeyTrack:
|
||||
return keyTrackFromNorm(norm);
|
||||
case DeckParam::kTrigLength:
|
||||
// lengthFraction is (0,1]; a small floor so a zero-length trigger never plays
|
||||
// nothing.
|
||||
return (std::max)(0.01, clamp01(norm));
|
||||
default:
|
||||
break;
|
||||
}
|
||||
// The rest are decided by the display unit alone, which is what makes the fourteen stage
|
||||
// times and the twelve curve dials one line each rather than twenty-six.
|
||||
switch (deckParamUnit(id)) {
|
||||
case UnitCategory::Milliseconds: return timeSecondsFromNorm(norm);
|
||||
case UnitCategory::Exponent: return util::curveFromKnobNorm(norm);
|
||||
case UnitCategory::Percent: return clamp01(norm); // hold fractions, sustain levels
|
||||
// Decibels is master gain, whose stored value is a LINEAR gain the processor owns
|
||||
// rather than a field of the parameter set; the two enums above are handled by id.
|
||||
case UnitCategory::Semitones:
|
||||
case UnitCategory::Decibels:
|
||||
case UnitCategory::None:
|
||||
break;
|
||||
}
|
||||
return norm;
|
||||
}
|
||||
|
||||
void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment) {
|
||||
switch (id) {
|
||||
case DeckParam::kPlayMode:
|
||||
@@ -112,88 +155,24 @@ void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment) {
|
||||
case DeckParam::kPitchEngine:
|
||||
play.pitchEngine = (segment == 1) ? PitchEngine::Preserve : PitchEngine::Varispeed;
|
||||
break;
|
||||
case DeckParam::kRate:
|
||||
play.playRate = rateRatioFromNorm(value, kRateMinRatio, kRateMaxRatio); break;
|
||||
case DeckParam::kPitch:
|
||||
play.pitchOffsetSemitones = depthSemitonesFromNorm(value, kPitchDepthMaxSemis); break;
|
||||
case DeckParam::kAttack: play.adsr.attackSeconds = timeSecondsFromNorm(value); break;
|
||||
case DeckParam::kHold: play.adsr.holdSeconds = timeSecondsFromNorm(value); break;
|
||||
case DeckParam::kDecay: play.adsr.decaySeconds = timeSecondsFromNorm(value); break;
|
||||
case DeckParam::kSustain: play.adsr.sustainLevel = clamp01(value); break;
|
||||
case DeckParam::kRelease: play.adsr.releaseSeconds = timeSecondsFromNorm(value); break;
|
||||
case DeckParam::kAttackCurve: play.adsr.attackCurve = util::curveFromKnobNorm(value); break;
|
||||
case DeckParam::kDecayCurve: play.adsr.decayCurve = util::curveFromKnobNorm(value); break;
|
||||
case DeckParam::kReleaseCurve: play.adsr.releaseCurve = util::curveFromKnobNorm(value); break;
|
||||
case DeckParam::kTrigLength:
|
||||
// lengthFraction is (0,1]; keep a small floor so a zero-length trigger never plays
|
||||
// nothing.
|
||||
play.trigger.lengthFraction = (std::max)(0.01, clamp01(value));
|
||||
break;
|
||||
case DeckParam::kTrigAttack: play.trigAhd.attackSeconds = timeSecondsFromNorm(value); break;
|
||||
case DeckParam::kTrigHold: play.trigAhd.holdFraction = clamp01(value); break;
|
||||
case DeckParam::kTrigDecay: play.trigAhd.decaySeconds = timeSecondsFromNorm(value); break;
|
||||
case DeckParam::kTrigAttackCurve:
|
||||
play.trigAhd.attackCurve = util::curveFromKnobNorm(value); break;
|
||||
case DeckParam::kTrigDecayCurve:
|
||||
play.trigAhd.decayCurve = util::curveFromKnobNorm(value); break;
|
||||
case DeckParam::kPitchEnvEnable: play.pitchEnv.enabled = (segment == 1); break;
|
||||
case DeckParam::kPitchEnvAttack:
|
||||
play.pitchEnv.shape.attackSeconds = timeSecondsFromNorm(value); break;
|
||||
case DeckParam::kPitchEnvHold:
|
||||
play.pitchEnv.shape.holdFraction = clamp01(value); break;
|
||||
case DeckParam::kPitchEnvDecay:
|
||||
play.pitchEnv.shape.decaySeconds = timeSecondsFromNorm(value); break;
|
||||
case DeckParam::kPitchEnvAttackCurve:
|
||||
play.pitchEnv.shape.attackCurve = util::curveFromKnobNorm(value); break;
|
||||
case DeckParam::kPitchEnvDecayCurve:
|
||||
play.pitchEnv.shape.decayCurve = util::curveFromKnobNorm(value); break;
|
||||
case DeckParam::kPitchEnvDepth:
|
||||
play.pitchEnv.peakSemitones = depthSemitonesFromNorm(value, kPitchDepthMaxSemis);
|
||||
break;
|
||||
case DeckParam::kFilterEnable: play.filter.enabled = (segment == 1); break;
|
||||
case DeckParam::kFilterLaw:
|
||||
play.filter.settings.morphLaw =
|
||||
(segment == 1) ? MorphLaw::HighNotchLow : MorphLaw::HighBandLow;
|
||||
break;
|
||||
case DeckParam::kFilterMorph:
|
||||
play.filter.settings.morphNorm = static_cast<float>(clamp01(value)); break;
|
||||
case DeckParam::kFilterCutoff:
|
||||
play.filter.settings.cutoffNorm = static_cast<float>(clamp01(value)); break;
|
||||
case DeckParam::kFilterQ:
|
||||
play.filter.settings.resonanceNorm = static_cast<float>(clamp01(value)); break;
|
||||
case DeckParam::kFilterDrive:
|
||||
play.filter.settings.driveNorm = static_cast<float>(clamp01(value)); break;
|
||||
case DeckParam::kFilterModAmt: play.filter.modAmount = deckBipolarFromNorm(value); break;
|
||||
case DeckParam::kFilterVel: play.filter.velAmount = deckBipolarFromNorm(value); break;
|
||||
case DeckParam::kFilterKeyTrack:
|
||||
play.filter.keyTrack = clamp01(value) * kKeyTrackMax; break;
|
||||
case DeckParam::kFilterEnvAttack:
|
||||
play.filter.env.attackSeconds = timeSecondsFromNorm(value); break;
|
||||
case DeckParam::kFilterEnvHold:
|
||||
play.filter.env.holdSeconds = timeSecondsFromNorm(value); break;
|
||||
case DeckParam::kFilterEnvDecay:
|
||||
play.filter.env.decaySeconds = timeSecondsFromNorm(value); break;
|
||||
case DeckParam::kFilterEnvSustain:
|
||||
play.filter.env.sustainLevel = clamp01(value); break;
|
||||
case DeckParam::kFilterEnvRelease:
|
||||
play.filter.env.releaseSeconds = timeSecondsFromNorm(value); break;
|
||||
case DeckParam::kFilterEnvAttackCurve:
|
||||
play.filter.env.attackCurve = util::curveFromKnobNorm(value); break;
|
||||
case DeckParam::kFilterEnvDecayCurve:
|
||||
play.filter.env.decayCurve = util::curveFromKnobNorm(value); break;
|
||||
case DeckParam::kFilterEnvReleaseCurve:
|
||||
play.filter.env.releaseCurve = util::curveFromKnobNorm(value); break;
|
||||
case DeckParam::kFilterTrigAttack:
|
||||
play.filter.trigEnv.attackSeconds = timeSecondsFromNorm(value); break;
|
||||
case DeckParam::kFilterTrigHold:
|
||||
play.filter.trigEnv.holdFraction = clamp01(value); break;
|
||||
case DeckParam::kFilterTrigDecay:
|
||||
play.filter.trigEnv.decaySeconds = timeSecondsFromNorm(value); break;
|
||||
case DeckParam::kFilterTrigAttackCurve:
|
||||
play.filter.trigEnv.attackCurve = util::curveFromKnobNorm(value); break;
|
||||
case DeckParam::kFilterTrigDecayCurve:
|
||||
play.filter.trigEnv.decayCurve = util::curveFromKnobNorm(value); break;
|
||||
default: break;
|
||||
default:
|
||||
// Every knob: the one norm -> stored law, into the one field the control names.
|
||||
// Both halves are shared with the audio thread's live patch (param/param_live), so
|
||||
// a control cannot take a different taper or land in a different field depending on
|
||||
// which surface wrote it. A toggle or a value living outside PlaySeconds resolves to
|
||||
// neither field and falls through untouched.
|
||||
if (float* f = deckFloatField(id, play)) {
|
||||
*f = static_cast<float>(storedFromNorm(id, value));
|
||||
} else if (double* d = deckDoubleField(id, play)) {
|
||||
*d = storedFromNorm(id, value);
|
||||
}
|
||||
break;
|
||||
}
|
||||
// ONE normalization point for every control that can flip splineActive — a mode toggle
|
||||
// (above) or an enable toggle (kPitchEnvEnable/kFilterEnable), whose enabling can make an
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
#include "core/instrument/ui/deck_groups.h" // DeckParam
|
||||
#include "core/instrument/ui/envelope_overlay.h" // kGateStageMaxSeconds
|
||||
#include "core/instrument/ui/param_taper.h" // UnitCategory + the shared tapers
|
||||
#include "core/util/clamp01.h"
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
@@ -28,6 +29,13 @@ inline constexpr double kPitchDepthMaxSemis = kVelocityPitchRangeSemitones;
|
||||
// Key-track knob ceiling (0..200%), shared by the pitch and filter key-track controls.
|
||||
inline constexpr double kKeyTrackMax = 2.0;
|
||||
|
||||
// The pitch key-track scalar lives beside the play bundle (on InstrumentParams / SampleData),
|
||||
// so its two conversions cannot ride the PlaySeconds binding below. One home for them anyway:
|
||||
// the editor knob, the host's write path and the live fold would otherwise each spell the
|
||||
// division out.
|
||||
inline double keyTrackFromNorm(double norm) { return util::clamp01(norm) * kKeyTrackMax; }
|
||||
inline double keyTrackNormFrom(double keyTrack) { return util::clamp01(keyTrack / kKeyTrackMax); }
|
||||
|
||||
// Rate's range: ALIASES of the stretcher's own measured ratio bounds, so the knob's ends are the
|
||||
// engine's clamp rather than a second opinion of it. The taper takes them as arguments for the
|
||||
// same reason the depth taper takes its throw — engine/time_stretch.h owns the numbers.
|
||||
@@ -41,6 +49,12 @@ inline constexpr double kRateMaxRatio = engine::kStretchRateMax;
|
||||
// shell reads those from the processor.
|
||||
double deckParamNorm(DeckParam id, const PlaySeconds& play);
|
||||
|
||||
// The STORED value a knob's normalized position maps to — the norm -> value half of the binding
|
||||
// on its own, because the audio thread needs it without a PlaySeconds to write into
|
||||
// (`param/param_live`). setDeckParam IS this composed with the field lookup below, so the two
|
||||
// cannot carry different tapers. Answers `norm` unchanged for a control with no stored scalar.
|
||||
double storedFromNorm(DeckParam id, double norm);
|
||||
|
||||
// Applies a committed interaction: a knob's normalized `value`, or a toggle's `segment` (0/1).
|
||||
// Mutates `play` in place, touching exactly the one field the control names.
|
||||
void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment);
|
||||
|
||||
@@ -98,22 +98,34 @@ pure half — the frozen id table, the exposed set, the plain-value layer, the f
|
||||
next touch re-imposing, a superseded value. Master gain has its own funnel
|
||||
(`setMasterGainLinear`) because it is the one exposed control that does not ride the
|
||||
parameter set.
|
||||
- **`setState` ordering against the host's first parameter block is irrelevant by
|
||||
construction.** There is one model and one funnel per control, so whichever writes last wins
|
||||
and the host's display follows the model either way — the ordering is not assumed, it is
|
||||
removed as a question.
|
||||
- **`process()` reads no parameter queue and is unchanged by the parameter surface.** A host
|
||||
write arrives on the UI/main thread and reaches the audio thread through the SAME live block
|
||||
the editor's knobs publish into, observed once per `render()` — block boundaries, last write
|
||||
wins. `[verify — DAW]` that REAPER delivers automation to a single-component plug-in through
|
||||
`IEditController::setParamNormalized` and not through `ProcessData::inputParameterChanges`
|
||||
alone; if it is the latter only, an RT-safe drain is required and `process()` is where it
|
||||
would have to land.
|
||||
- **BOTH delivery channels are serviced, and the audio-side one is the normative one.**
|
||||
`IEditController::setParamNormalized` is the CONTROLLER channel — the SDK says a controller
|
||||
"should update the according GUI element(s) only" there, so nothing about the audio may depend
|
||||
on a host calling it. `ProcessData::inputParameterChanges` is the AUDIO channel, and the SDK's
|
||||
own single-component sample (`public.sdk/samples/vst/again/source/againsimple.cpp`) drains it
|
||||
in `process()` while also implementing `setParamNormalized`. We do both, for the same reason.
|
||||
- **The audio thread is the sole writer of the block the ENGINE reads.** Two `LiveParams`
|
||||
blocks: the model's publishers (editor commits, reload, `setState`) write `liveParams_` off
|
||||
the audio thread and may allocate on the way; `process()` merges that block with the host's
|
||||
automation points into `automationLive_`, which is what `SampleData::live` points at. Two
|
||||
blocks rather than one because the seqlock's single-writer contract is load-bearing and the two
|
||||
writers genuinely differ in thread. The merge republishes ONLY when either side moved, so a
|
||||
block carrying neither costs one relaxed load and the engine's read shape is unchanged.
|
||||
- **The automation values fold back into the model on the UI thread** (`drainAutomationToModel`,
|
||||
called from `getState`, the editor's sync tick, and the bake's reload tail). The blob is
|
||||
authoritative, so a value that never came back would be lost on save. The fold is suppressed
|
||||
from notifying the host — the values came FROM it, and echoing them would let a lane in write
|
||||
mode re-record its own playback.
|
||||
- **`setState` does not need an ordering guarantee against the host's first parameter block.**
|
||||
An automation point held by the audio thread is re-applied over every merge, so a written lane
|
||||
outranks the restore whichever way round the two arrive — which is VST3's own rule, not a race
|
||||
we lost.
|
||||
- **`IMidiMapping` is deliberately NOT implemented** — no conventional CC names most of what
|
||||
is exposed, an invented map would hijack CCs the user's controller already sends, and
|
||||
REAPER's own per-parameter MIDI learn covers the case without freezing anything.
|
||||
`IParameterFunctionName` and `IAutomationState` are assessed and not implemented; the reasons
|
||||
are in the product spec and are not re-surveyed here.
|
||||
`[verify — DAW]` REAPER's own per-parameter MIDI learn is expected to cover the case without
|
||||
freezing anything. `IParameterFunctionName` and `IAutomationState` are assessed and not
|
||||
implemented — `bake/CLAUDE.md` owns the `IAutomationState` reasoning, at its one consequence
|
||||
site.
|
||||
|
||||
**Non-goals / guardrails.**
|
||||
- The instrument never captures and never inserts into the arrange. Playback is a
|
||||
@@ -151,6 +163,13 @@ pure half — the frozen id table, the exposed set, the plain-value layer, the f
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **`reasampler_processor.h` is a documented ~600-line-ceiling exception** (root `CLAUDE.md`,
|
||||
structural heuristic 1), on the same footing as `voice.h`'s: it is ONE class declaration, so
|
||||
the seam the heuristic asks for does not exist — a split would be an arbitrary bisection, and
|
||||
the implementation is already split across three TUs on its real seams. Its bulk is the
|
||||
drain-slot proof, the RT-discipline constraints and the two-block automation contract, all of
|
||||
which the comment conventions name as keep-worthy. Not silent overshoot.
|
||||
|
||||
- **The bake click only ARMS; the editor's sync tick runs it.** Calling
|
||||
`Main_OnCommandEx` inline from `WM_LBUTTONDOWN` would run the extension's whole landing
|
||||
nested inside a mouse handler with `SetCapture` held, while the invoked action re-points
|
||||
|
||||
@@ -90,7 +90,7 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp")
|
||||
waveform_view loop_marks bank_sync browser_scroll param_slider tooltip
|
||||
theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit
|
||||
knob_deck deck_groups deck_values curve_popup spline_edit master_gain sample_usage
|
||||
param_id param_units param_format
|
||||
param_id param_units param_format param_live
|
||||
limiter meter_accumulate meter_ballistics master_meter bake_hold
|
||||
file_bytes curve_law stroke_aa
|
||||
curve_tessellate
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
// editor_controls.cpp — the ReaSamplerEditor's parameter plumbing: the band-stack layout
|
||||
// resolve every paint/hit-test path shares, the shell's half of the control-value binding (the
|
||||
// per-instance controls the parameter set does not carry — key-track, voice count, master gain,
|
||||
// preview velocity — plus the value labels), and the node-drag clamp bounds. The parameter-set
|
||||
// half is the pure `deck_values` module. The orthogonal half — which stored struct each editor
|
||||
// selection names — is editor_models. Value logic only: no painting, no window plumbing.
|
||||
// preview velocity — plus each knob's plain value and its label), and the node-drag clamp
|
||||
// bounds. The parameter-set half is the pure `deck_values` module. The orthogonal half — which
|
||||
// stored struct each editor selection names — is editor_models. Value logic only.
|
||||
|
||||
#include "shell/instrument/reasampler_editor.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath> // isfinite (the gain's -inf label)
|
||||
#include <cstdint>
|
||||
#include <cstdio> // snprintf (deck value labels)
|
||||
#include <string>
|
||||
@@ -241,11 +242,20 @@ std::string ReaSamplerEditor::deckValueLabel(int id) const {
|
||||
|
||||
// The digits come from the ONE formatter; everything the editor adds around them is static
|
||||
// chrome — a constant prefix or suffix cannot diverge from what the host shows.
|
||||
const double plain = deckPlainValue(id);
|
||||
char digits[24];
|
||||
instrument::param::formatPlainFor(deck, deckPlainValue(id), digits, sizeof(digits));
|
||||
instrument::param::formatPlainFor(deck, plain, digits, sizeof(digits));
|
||||
const auto kind = instrument::param::unitKindFor(deck);
|
||||
const char* caret =
|
||||
instrument::ui::deckParamUnit(deck) == instrument::ui::UnitCategory::Exponent ? "^" : "";
|
||||
return caret + std::string(digits) + instrument::param::unitStringFor(deck);
|
||||
// The gain at true silence reads "-inf", not "-infdB": there is no decibel value there.
|
||||
if (kind == instrument::param::UnitKind::Decibels && !std::isfinite(plain)) {
|
||||
return std::string(digits);
|
||||
}
|
||||
// The stage times are the one category that carries a space before its unit, and always did —
|
||||
// this surface's own typography, not the host's (ParameterInfo::units is the bare string).
|
||||
const char* gap = kind == instrument::param::UnitKind::Time ? " " : "";
|
||||
return caret + std::string(digits) + gap + instrument::param::unitStringFor(deck);
|
||||
}
|
||||
|
||||
EnvClampBounds ReaSamplerEditor::envClampBounds() const {
|
||||
|
||||
@@ -119,6 +119,10 @@ bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) {
|
||||
dragStartEnv_ = env;
|
||||
dragSampleFrames_ = frames;
|
||||
dragStartParams_ = params_;
|
||||
// Peer of mouseDownDeck's bracket. LATCHING with no id named, because a node or
|
||||
// knot drag can move more than one exposed parameter and the grab cannot know
|
||||
// which; the release and capture-lost paths close it generically.
|
||||
if (processor_) processor_->beginParamGestureLatch();
|
||||
return true; // node moves once the cursor drags
|
||||
}
|
||||
return splineOverlayClick(overlay, x, y, gesture, /*addOnEmptySpace=*/false);
|
||||
|
||||
@@ -120,6 +120,11 @@ void ReaSamplerEditor::attachedToParent() {
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::removedFromParent() {
|
||||
// The processor outlives this view, so a bracket left open here would leave the host holding
|
||||
// an edit forever and every later internal write to that parameter would emit a bare
|
||||
// performEdit. The capture-lost path normally closes it; this does not rely on Windows
|
||||
// delivering WM_CAPTURECHANGED before the window goes away.
|
||||
if (processor_) processor_->endParamGesture();
|
||||
if (childHwnd_) {
|
||||
KillTimer(childHwnd_, kSyncTimerId); // stop the poll before the window goes away
|
||||
DestroyWindow(childHwnd_);
|
||||
|
||||
@@ -141,6 +141,10 @@ void ReaSamplerEditor::onSyncTimer() {
|
||||
// edit surface exactly as a reload would. Unconditional: it self-cancels when nothing is
|
||||
// armed, so no commit site has to remember to ask for it.
|
||||
processor_->flushLatencyRestart();
|
||||
// Peers of it: both deliver work the originating thread could not do where it stood — a host
|
||||
// callback from inside reloadMutex_, and a model write from the audio thread.
|
||||
processor_->flushGainNotify();
|
||||
processor_->drainAutomationToModel();
|
||||
|
||||
// Resolve the bake affordance's availability on the SAME tick that paints it, so it
|
||||
// can never be enabled on one tick and refuse on the next.
|
||||
|
||||
@@ -120,6 +120,9 @@ BakeChainResult runBake(ReaSamplerProcessor& processor) {
|
||||
const std::optional<Tempo> tempo = Tempo::fromBpm(bridge.projectTempoBpm());
|
||||
if (!tempo) return fail("the project tempo could not be read");
|
||||
|
||||
// Fold anything the host's automation wrote into the model FIRST: the bake renders the sound
|
||||
// the user approved, and an automated value the model has not picked up yet is part of it.
|
||||
processor.drainAutomationToModel();
|
||||
const InstrumentParams dialed = processor.instrumentParams();
|
||||
const int rootNote = dialed.rootOverride ? *dialed.rootOverride : source->rootNote;
|
||||
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
// instrument_params.cpp — the VST3 adapter over core/instrument/param: the Parameter subclass
|
||||
// whose toPlain/toNormalized ARE the taper, the one construction of the unit and parameter
|
||||
// lists, and the model projection both directions. It DECIDES nothing — the pure module owns
|
||||
// the frozen table, the laws and the formatter.
|
||||
//
|
||||
// The blob stays authoritative. A parameter is a THIRD SURFACE onto InstrumentParams/PlaySeconds
|
||||
// — a peer of the deck knob and the overlay node, never a second copy of the value. getState
|
||||
// serializes the model; the controller's own value list is a cache written FROM the model and
|
||||
// never read as truth.
|
||||
// whose toPlain/toNormalized ARE the taper, the construction of the unit and parameter lists,
|
||||
// the model projection both directions, and both delivery channels (the controller's write and
|
||||
// the audio thread's queue drain). It DECIDES nothing — the pure module owns the frozen table,
|
||||
// the laws and the formatter.
|
||||
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
|
||||
#include "base/source/fstring.h"
|
||||
#include "pluginterfaces/base/ustring.h"
|
||||
#include "pluginterfaces/vst/ivstparameterchanges.h" // IParameterChanges / IParamValueQueue
|
||||
|
||||
#include "core/instrument/engine/master_gain.h"
|
||||
#include "core/instrument/param/param_format.h"
|
||||
@@ -123,7 +120,7 @@ tresult PLUGIN_API ReaSamplerProcessor::setParamNormalized(ParamID tag, ParamVal
|
||||
setMasterGainLinear(instrument::engine::masterGainLinearFromNorm(value));
|
||||
} else {
|
||||
InstrumentParams params = instrumentParams();
|
||||
instrument::ui::setDeckParam(row->deck, params.play, value, /*segment=*/0);
|
||||
writeDeckParamToModel(params, row->deck, value);
|
||||
setInstrumentParams(params);
|
||||
publishLiveParams();
|
||||
}
|
||||
@@ -134,11 +131,25 @@ tresult PLUGIN_API ReaSamplerProcessor::setParamNormalized(ParamID tag, ParamVal
|
||||
tag, modelParamNormalized(instrumentParams(), row->deck));
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::writeDeckParamToModel(InstrumentParams& params, DeckParam deck,
|
||||
double normalized) {
|
||||
// The two homes a control's value can have, and the ONE place the host path knows the
|
||||
// difference — the editor draws the same split at applyParamControl. param::valueHomeFor is
|
||||
// the predicate; a promotion whose control has neither home fails param_units' own test
|
||||
// rather than no-oping silently here.
|
||||
if (deck == DeckParam::kKeyTrack) {
|
||||
params.keyTrack = instrument::ui::keyTrackFromNorm(normalized);
|
||||
return;
|
||||
}
|
||||
instrument::ui::setDeckParam(deck, params.play, normalized, /*segment=*/0);
|
||||
}
|
||||
|
||||
double ReaSamplerProcessor::modelParamNormalized(const InstrumentParams& params,
|
||||
DeckParam deck) const {
|
||||
if (deck == DeckParam::kMasterGain) {
|
||||
return instrument::engine::masterGainNormFromLinear(masterGainLinear());
|
||||
}
|
||||
if (deck == DeckParam::kKeyTrack) return instrument::ui::keyTrackNormFrom(params.keyTrack);
|
||||
return instrument::ui::deckParamNorm(deck, params.play);
|
||||
}
|
||||
|
||||
@@ -152,43 +163,145 @@ void ReaSamplerProcessor::syncParamsFromModel() {
|
||||
}
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::notifyParamsFromModel(const InstrumentParams& before,
|
||||
void ReaSamplerProcessor::notifyParamsFromModel(const double* beforeNorms,
|
||||
const InstrumentParams& after) {
|
||||
if (paramNotifySuppressed_) return;
|
||||
// The bake's reset moves ~40 values at once. Grouping them tells the host they are ONE act,
|
||||
// which is what an undo stack and an automation lane both want; the SDK provides exactly
|
||||
// this for exactly this case (ivsteditcontroller.h, IComponentHandler2).
|
||||
const bool group = componentHandler2 && !gestureLatching_;
|
||||
if (group) componentHandler2->startGroupEdit();
|
||||
for (const param::ParamRow& row : param::exposedParams()) {
|
||||
if (row.deck == DeckParam::kMasterGain) continue; // its own funnel notifies it
|
||||
const double now = modelParamNormalized(after, row.deck);
|
||||
if (now == modelParamNormalized(before, row.deck)) continue;
|
||||
if (now == beforeNorms[static_cast<std::size_t>(row.deck)]) continue;
|
||||
notifyParamChanged(row.id, now);
|
||||
}
|
||||
if (group) componentHandler2->finishGroupEdit();
|
||||
}
|
||||
|
||||
bool ReaSamplerProcessor::gestureIsOpen(param::ParamId id) const {
|
||||
for (std::size_t i = 0; i < openGestureCount_; ++i) {
|
||||
if (openGestureIds_[i] == id) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::notifyParamChanged(param::ParamId id, double normalized) {
|
||||
EditControllerEx1::setParamNormalized(id, normalized);
|
||||
if (!componentHandler) return;
|
||||
// A drag holds its own begin/end across the whole gesture so a host in touch or latch mode
|
||||
// sees one continuous edit; every other writer — a reset, an envelope-node drag, the bake's
|
||||
// reset — emits a degenerate one-point gesture, which is what makes the host DISPLAY follow
|
||||
// it instead of re-imposing the pre-write value on the next touch.
|
||||
const bool inGesture = openGestureId_ == id;
|
||||
if (!inGesture) beginEdit(id);
|
||||
performEdit(id, normalized);
|
||||
if (!inGesture) endEdit(id);
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::beginParamGesture(DeckParam deck) {
|
||||
const param::ParamId id = param::paramIdFor(deck);
|
||||
if (id == 0 || !param::isExposed(deck)) return;
|
||||
endParamGesture(); // a grab while one is open cannot leave the previous unclosed
|
||||
openGestureId_ = id;
|
||||
if (gestureIsOpen(id)) {
|
||||
performEdit(id, normalized);
|
||||
return;
|
||||
}
|
||||
if (gestureLatching_ && openGestureCount_ < kMaxOpenGestures) {
|
||||
// First move this drag has made on this parameter: open its bracket and hold it, so the
|
||||
// whole drag is one edit rather than a run of one-point ones.
|
||||
openGestureIds_[openGestureCount_++] = id;
|
||||
beginEdit(id);
|
||||
performEdit(id, normalized);
|
||||
return;
|
||||
}
|
||||
// Every non-drag writer — a reset, the bake's reset — emits a degenerate one-point gesture,
|
||||
// which is what makes the host DISPLAY follow it instead of re-imposing the pre-write value
|
||||
// on the next touch.
|
||||
beginEdit(id);
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::endParamGesture() {
|
||||
if (openGestureId_ == 0) return;
|
||||
const param::ParamId id = openGestureId_;
|
||||
openGestureId_ = 0; // cleared FIRST: endEdit can re-enter through a host's own callback
|
||||
performEdit(id, normalized);
|
||||
endEdit(id);
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::beginParamGestureLatch() {
|
||||
endParamGesture(); // a grab while one is open cannot leave the previous unclosed
|
||||
gestureLatching_ = true;
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::beginParamGesture(DeckParam deck) {
|
||||
beginParamGestureLatch();
|
||||
const param::ParamId id = param::paramIdFor(deck);
|
||||
if (id == 0 || !param::isExposed(deck)) return;
|
||||
openGestureIds_[openGestureCount_++] = id;
|
||||
beginEdit(id);
|
||||
}
|
||||
|
||||
bool ReaSamplerProcessor::drainInputParameterChanges(IParameterChanges* changes) {
|
||||
if (!changes) return false;
|
||||
// RT-SAFE, and the one non-obvious part of that: exposedRowFor walks exposedParams(), whose
|
||||
// backing vector is a function-local static built on FIRST CALL. buildParameterList() calls
|
||||
// it from initialize(), which the SDK guarantees precedes any process() — so the allocation
|
||||
// has already happened by the time the audio thread gets here.
|
||||
bool landed = false;
|
||||
const int32 queues = changes->getParameterCount();
|
||||
for (int32 q = 0; q < queues; ++q) {
|
||||
IParamValueQueue* queue = changes->getParameterData(q);
|
||||
if (!queue) continue;
|
||||
const int32 points = queue->getPointCount();
|
||||
if (points <= 0) continue;
|
||||
// The LAST point of the queue wins for the block. Applying every point at its sample
|
||||
// offset would put a "did anything change" question on the per-voice-per-sample path,
|
||||
// which the phase-wide guardrail forbids.
|
||||
int32 offset = 0;
|
||||
ParamValue value = 0.0;
|
||||
if (queue->getPoint(points - 1, offset, value) != kResultTrue) continue;
|
||||
const param::ParamRow* row = param::exposedRowFor(queue->getParameterId());
|
||||
if (!row) continue;
|
||||
landed = true;
|
||||
const auto slot = static_cast<std::size_t>(row->deck);
|
||||
automationNorm_[slot] = value;
|
||||
automationHeld_[slot] = true;
|
||||
// Master gain reaches the audio beside the block rather than through it, so its
|
||||
// automation write is the same one relaxed store the knob makes.
|
||||
if (row->deck == DeckParam::kMasterGain) {
|
||||
masterGain_.store(
|
||||
static_cast<float>(instrument::engine::masterGainLinearFromNorm(value)),
|
||||
std::memory_order_relaxed);
|
||||
}
|
||||
// Publish to the UI thread, which folds it back into the model — the blob stays
|
||||
// authoritative, so a value that never came back would be lost on save.
|
||||
automationPublished_[slot].store(value, std::memory_order_relaxed);
|
||||
automationPending_[slot].store(true, std::memory_order_release);
|
||||
}
|
||||
if (landed) automationAny_.store(true, std::memory_order_release);
|
||||
return landed;
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::drainAutomationToModel() {
|
||||
if (!automationAny_.exchange(false, std::memory_order_acquire)) return;
|
||||
InstrumentParams params = instrumentParams();
|
||||
bool moved = false;
|
||||
for (const param::ParamRow& row : param::exposedParams()) {
|
||||
const auto slot = static_cast<std::size_t>(row.deck);
|
||||
if (!automationPending_[slot].exchange(false, std::memory_order_acquire)) continue;
|
||||
const double value = automationPublished_[slot].load(std::memory_order_relaxed);
|
||||
// Master gain's model IS the atomic the audio thread already wrote; there is nothing to
|
||||
// fold, only the controller cache to refresh below.
|
||||
if (row.deck != DeckParam::kMasterGain) {
|
||||
writeDeckParamToModel(params, row.deck, value);
|
||||
moved = true;
|
||||
}
|
||||
}
|
||||
// Suppressed for the whole fold: these values CAME from the host, and echoing them back
|
||||
// through performEdit would let a lane in write mode re-record its own playback. The
|
||||
// controller cache is still refreshed, so the host's display and the editor follow.
|
||||
const bool wasSuppressed = paramNotifySuppressed_;
|
||||
paramNotifySuppressed_ = true;
|
||||
if (moved) {
|
||||
setInstrumentParams(params);
|
||||
publishLiveParams();
|
||||
}
|
||||
syncParamsFromModel();
|
||||
paramNotifySuppressed_ = wasSuppressed;
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::endParamGesture() {
|
||||
gestureLatching_ = false;
|
||||
if (openGestureCount_ == 0) return;
|
||||
// Latched into a local and the state cleared FIRST: endEdit can re-enter through a host's
|
||||
// own callback, and must not find a bracket this call is in the middle of closing.
|
||||
param::ParamId closing[kMaxOpenGestures];
|
||||
const std::size_t count = openGestureCount_;
|
||||
for (std::size_t i = 0; i < count; ++i) closing[i] = openGestureIds_[i];
|
||||
openGestureCount_ = 0;
|
||||
for (std::size_t i = 0; i < count; ++i) endEdit(closing[i]);
|
||||
}
|
||||
|
||||
} // namespace reasampler::vst
|
||||
|
||||
@@ -146,16 +146,18 @@ std::string ReaSamplerProcessor::reloadInstrument() {
|
||||
buildFromRef(*sel, params, projectDir, mode)) {
|
||||
sample = std::move(*decoded);
|
||||
havePlayable = true;
|
||||
// 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_;
|
||||
// Point the built snapshot at the instance's ONE engine-facing block and seed the
|
||||
// MODEL block 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. process() merges the seed into automationLive_ at the
|
||||
// top of the first block after this, which is where the swap below becomes visible
|
||||
// too — so the new snapshot's first note reads it.
|
||||
sample.live = &automationLive_;
|
||||
{
|
||||
// reloadMutex_ (held for this whole function) nests livePublishMutex_ here;
|
||||
// publishLiveParams never holds reloadMutex_, so this is the only nesting.
|
||||
std::lock_guard<std::mutex> lp(livePublishMutex_);
|
||||
liveParams_.publish(instrument::engine::foldLive(sample.play));
|
||||
liveParams_.publish(instrument::engine::foldLive(sample.play, sample.keyTrack));
|
||||
}
|
||||
builtSampleRate_.store(sample.sampleRate, std::memory_order_relaxed);
|
||||
resolvedId = selId; // the concrete pick that resolved
|
||||
@@ -226,6 +228,8 @@ void ReaSamplerProcessor::adoptBakedCapture(const SampleRefEntry& entry,
|
||||
// bake chain only ever runs from that tick, so the arm would be drained on the next one
|
||||
// anyway. At the tail for the same reason setState's is (see there).
|
||||
flushLatencyRestart();
|
||||
// The reset's gain notification, armed under reloadMutex_ inside that reload.
|
||||
flushGainNotify();
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::publishUsage(const SampleRefs& refs,
|
||||
@@ -289,8 +293,16 @@ void ReaSamplerProcessor::publishBuiltLocked(std::unique_ptr<LoadedInstrument> b
|
||||
// gain sitting above every snapshot, the same shape as ONE BLOCK, ONE RATE (see
|
||||
// builtSampleRate_).
|
||||
if (gainAtNextPublish_) {
|
||||
setMasterGainLinear(*gainAtNextPublish_);
|
||||
// The MIRROR is inline (that is the sound this publish belongs to); the host
|
||||
// notification is ARMED and delivered after reloadMutex_ is released. performEdit
|
||||
// reaches the host handler, a host may re-enter this object synchronously from it, and
|
||||
// setActive(false) takes this same non-recursive mutex — the identical hazard the
|
||||
// latency restart is deferred for.
|
||||
const bool moved = publishMasterGainLinear(*gainAtNextPublish_);
|
||||
gainAtNextPublish_.reset();
|
||||
// Armed only on a real change, so a reset that lands on the gain already set writes
|
||||
// nothing into a host's automation lane — the same compare setMasterGainLinear makes.
|
||||
if (moved) gainNotifyPending_.store(true, std::memory_order_release);
|
||||
}
|
||||
LoadedInstrument* evicted = draining_.exchange(prev);
|
||||
if (evicted) graveyard_.push_back(std::unique_ptr<LoadedInstrument>(evicted));
|
||||
|
||||
@@ -91,20 +91,26 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) {
|
||||
legacyLiftConcluded_.store(false, std::memory_order_relaxed);
|
||||
reloadInstrument();
|
||||
paramNotifySuppressed_ = false;
|
||||
// Every exposed parameter now reads the blob's value. Ordering against the host's first
|
||||
// parameter block is irrelevant BY CONSTRUCTION rather than by assumption: there is one
|
||||
// model and one funnel per control, so whichever of the two writes last simply wins, and
|
||||
// the host's display follows the model either way.
|
||||
// Every exposed parameter now reads the blob's value. Its ordering against the host's first
|
||||
// parameter block does not need to be known: an automation point held by the audio thread is
|
||||
// re-applied over every merge, so a lane outranks this restore whichever way round the two
|
||||
// arrive. That is VST3's own rule — a written lane outranks anything the plug-in sets — not
|
||||
// a race we lost.
|
||||
syncParamsFromModel();
|
||||
// This caller has no editor to flush for it. At the TAIL on purpose: a host that services the
|
||||
// restart synchronously deactivates/reactivates, and our setActive(true) resumes or reloads
|
||||
// against the refs above, which are only fully restored once this function has run to here.
|
||||
flushLatencyRestart();
|
||||
flushGainNotify();
|
||||
return kResultOk;
|
||||
}
|
||||
|
||||
tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) {
|
||||
if (!state) return kResultFalse;
|
||||
// Before the snapshot, not after: the blob is authoritative, so anything the host's
|
||||
// automation wrote must be in the model by the time it is serialised. This is the one drain
|
||||
// site that is not an optimisation — a save with no editor open still has to see it.
|
||||
drainAutomationToModel();
|
||||
// Persists the full instance state — never written to the "reasampler" bank ext-state.
|
||||
// No pick serializes to {"", default params}, restoring as silence (never auto-playing
|
||||
// sample #1).
|
||||
@@ -163,10 +169,15 @@ InstrumentParams ReaSamplerProcessor::instrumentParams() {
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::setInstrumentParams(const InstrumentParams& params) {
|
||||
InstrumentParams before;
|
||||
// The exposed values alone, not the whole set: this funnel fires per mouse move on every live
|
||||
// knob and node drag, and InstrumentParams owns seven vectors — copying all of them to diff
|
||||
// 44 doubles is the cost, and the diff is what the notification actually needs.
|
||||
double before[kDeckParamSlots];
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(paramsMutex_);
|
||||
before = params_;
|
||||
for (const instrument::param::ParamRow& row : instrument::param::exposedParams()) {
|
||||
before[static_cast<std::size_t>(row.deck)] = modelParamNormalized(params_, row.deck);
|
||||
}
|
||||
params_ = params;
|
||||
}
|
||||
// Every writer of the parameter set — setState, the editor's commits, the bake's adopt —
|
||||
@@ -250,8 +261,9 @@ void ReaSamplerProcessor::clearMasterBusClip() {
|
||||
void ReaSamplerProcessor::publishLiveParams() {
|
||||
const int rate = builtSampleRate_.load(std::memory_order_relaxed);
|
||||
if (rate <= 0) return;
|
||||
const InstrumentParams params = instrumentParams();
|
||||
const instrument::engine::LiveValues block =
|
||||
instrument::engine::foldLive(resolvePlay(instrumentParams().play, rate));
|
||||
instrument::engine::foldLive(resolvePlay(params.play, rate), params.keyTrack);
|
||||
// livePublishMutex_ enforces the seqlock's single-writer contract (live_params.h) against
|
||||
// reloadInstrument's publish — held for the publish call only, not the fold above.
|
||||
std::lock_guard<std::mutex> lock(livePublishMutex_);
|
||||
@@ -329,20 +341,32 @@ void ReaSamplerProcessor::setMonoTrigger(MonoTrigger trigger) {
|
||||
rebuildVoiceEngine();
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::setMasterGainLinear(double linear) {
|
||||
bool ReaSamplerProcessor::publishMasterGainLinear(double linear) {
|
||||
// Clamp to the master_gain taper (0 = silence, cap = +24 dB). One relaxed atomic
|
||||
// store — no rebuild, no lock (a post-sum trim is not a keymap fact).
|
||||
if (!(linear >= 0.0)) linear = 0.0; // also catches NaN
|
||||
const double maxLin = masterGainMaxLinear();
|
||||
if (linear > maxLin) linear = maxLin;
|
||||
const float value = static_cast<float>(linear);
|
||||
const float previous = masterGain_.exchange(value, std::memory_order_relaxed);
|
||||
return masterGain_.exchange(value, std::memory_order_relaxed) != value;
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::setMasterGainLinear(double linear) {
|
||||
const bool moved = publishMasterGainLinear(linear);
|
||||
// Gain's own notification funnel — it is the one exposed control that does not ride the
|
||||
// parameter set, so setInstrumentParams' diff cannot see it. Compared for a real change so a
|
||||
// reload's republish of an unmoved gain writes nothing into a host's automation lane.
|
||||
if (paramNotifySuppressed_ || previous == value) return;
|
||||
if (paramNotifySuppressed_ || !moved) return;
|
||||
notifyParamChanged(instrument::param::kParamMasterGain,
|
||||
instrument::engine::masterGainNormFromLinear(linear));
|
||||
instrument::engine::masterGainNormFromLinear(masterGainLinear()));
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::flushGainNotify() {
|
||||
if (!componentHandler) return; // an arm raised before the handler connected waits
|
||||
if (!gainNotifyPending_.exchange(false, std::memory_order_acquire)) return;
|
||||
if (paramNotifySuppressed_) return;
|
||||
notifyParamChanged(instrument::param::kParamMasterGain,
|
||||
instrument::engine::masterGainNormFromLinear(masterGainLinear()));
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::previewNoteOn(int note) {
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
#include "pluginterfaces/vst/ivstmidicontrollers.h" // kCtrlAllNotesOff / kCtrlAllSoundsOff (panic)
|
||||
#include "pluginterfaces/vst/vstspeaker.h"
|
||||
|
||||
#include "core/instrument/engine/master_gain.h" // the automation write of the gain's own atomic
|
||||
#include "core/instrument/param/param_live.h" // the RT-safe patch of one control into the block
|
||||
#include "shell/instrument/reasampler_editor.h" // createView hands the host our IPlugView editor
|
||||
#include "shell/instrument/reasampler_embed.h" // embed shell + IReaperUIEmbedInterface (its iid DEF'd there)
|
||||
|
||||
@@ -200,6 +202,33 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
|
||||
(drain && drain->fullyIdle()) ? drain->installedAt : 0,
|
||||
std::memory_order_relaxed);
|
||||
|
||||
// Host automation, merged into the engine-facing block BEFORE the note marshalling below, so
|
||||
// a note-on in this block latches this block's values. See automationLive_ (header) for why
|
||||
// the merge is here rather than in the model's own publisher.
|
||||
{
|
||||
const bool dirty = drainInputParameterChanges(data.inputParameterChanges);
|
||||
const std::uint32_t modelGen = liveParams_.generation();
|
||||
if (dirty || modelGen != seenModelGeneration_) {
|
||||
// Declared INSIDE the branch: LiveValues carries default member initializers, so a
|
||||
// block where nothing moved must not pay to construct one.
|
||||
instrument::engine::LiveValues merged;
|
||||
// Re-read the model's fold and re-apply every held automation value over it: without
|
||||
// the re-apply, any knob move would revert an automated parameter until its lane's
|
||||
// next point.
|
||||
if (liveParams_.read(merged) != 0) {
|
||||
seenModelGeneration_ = modelGen;
|
||||
const int builtRate = builtSampleRate_.load(std::memory_order_relaxed);
|
||||
for (std::size_t i = 0; i < kDeckParamSlots; ++i) {
|
||||
if (!automationHeld_[i]) continue;
|
||||
instrument::param::applyLiveParam(
|
||||
merged, static_cast<instrument::ui::DeckParam>(i), automationNorm_[i],
|
||||
builtRate);
|
||||
}
|
||||
automationLive_.publish(merged);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Marshal MIDI note-on/off at block granularity (no per-event sample-offset split;
|
||||
// sample-accurate scheduling is a later tier). Note-offs also route to the drain
|
||||
// engine so a note held across a reload releases its old-snapshot voice too.
|
||||
|
||||
@@ -125,17 +125,12 @@ public:
|
||||
// Hands the host our LICE IPlugView editor.
|
||||
Steinberg::IPlugView* PLUGIN_API createView(Steinberg::FIDString name) override;
|
||||
|
||||
// A host write of one exposed parameter. Applies it to THE model through that control's
|
||||
// existing commit tier — live publish, or the master-gain atomic — and never through a
|
||||
// fourth route. Nothing reachable from here touches reloadInstrument or rebuildVoiceEngine,
|
||||
// which is structural rather than careful: every reload- and rebuild-tier control is omitted
|
||||
// from the parameter list, so no id maps to one.
|
||||
//
|
||||
// Parameter values reach the audio thread through the SAME block the editor's knobs publish
|
||||
// into, which the engine observes ONCE per render() — i.e. at BLOCK BOUNDARIES, last write
|
||||
// wins for that block. Sample-accurate application would put a per-sample "did anything
|
||||
// change" question on the per-voice-per-sample path, which the phase-wide guardrail forbids.
|
||||
// process() reads no parameter queue and is unchanged by the parameter surface.
|
||||
// The CONTROLLER-side write — a host GUI gesture on the generic panel, and whatever a host
|
||||
// mirrors here for display. Applies it to THE model through that control's existing commit
|
||||
// tier and never through a fourth route. Nothing reachable from here touches reloadInstrument
|
||||
// or rebuildVoiceEngine, which is structural rather than careful: every reload- and
|
||||
// rebuild-tier control is omitted from the parameter list, so no id maps to one.
|
||||
// NOT the automation channel; this directory's CLAUDE.md owns which channel is which.
|
||||
Steinberg::tresult PLUGIN_API setParamNormalized(
|
||||
Steinberg::Vst::ParamID tag, Steinberg::Vst::ParamValue value) override;
|
||||
|
||||
@@ -144,10 +139,20 @@ public:
|
||||
// through IComponentHandler. UI/main thread.
|
||||
void syncParamsFromModel();
|
||||
|
||||
// A knob drag's host-edit bracket, so a host in touch or latch mode records ONE continuous
|
||||
// edit rather than a burst of one-point gestures. Idempotent: a grab while one is open
|
||||
// closes it first, and endParamGesture with none open does nothing. UI thread only.
|
||||
// Folds what the audio thread took from the host's parameter queue back into the model and
|
||||
// the controller cache, suppressing the host notification (those values came FROM it).
|
||||
// UI/main thread; a no-op when nothing was automated. Called wherever the model is about to
|
||||
// be READ as authoritative — getState, the bake, the editor's tick.
|
||||
void drainAutomationToModel();
|
||||
|
||||
// A drag's host-edit bracket, so a host in touch or latch mode records ONE continuous edit
|
||||
// per parameter rather than a burst of one-point ones. Every parameter the drag notifies
|
||||
// opens its bracket on first touch and holds it until endParamGesture — LATCHING rather than
|
||||
// declared up front, because an envelope-node drag moves a set the grab cannot name. The
|
||||
// deck-knob form additionally opens its own id at once, which is the id the host sees a touch
|
||||
// on even if the drag produces no move. Idempotent. UI thread only.
|
||||
void beginParamGesture(instrument::ui::DeckParam deck);
|
||||
void beginParamGestureLatch();
|
||||
void endParamGesture();
|
||||
|
||||
// Additionally exposes REAPER's IReaperUIEmbedInterface (queried by REAPER to drive the
|
||||
@@ -264,6 +269,9 @@ public:
|
||||
return static_cast<double>(masterGain_.load(std::memory_order_relaxed));
|
||||
}
|
||||
void setMasterGainLinear(double linear); // clamped to [0, masterGainMaxLinear()]
|
||||
// The mirror alone, with no host notification: for the one writer that runs under
|
||||
// reloadMutex_ and must arm rather than emit. True when the value actually moved.
|
||||
bool publishMasterGainLinear(double linear);
|
||||
|
||||
// The master-bus limiter's single enable (persisted in the parameter set). UI thread only:
|
||||
// a thin wrapper over setInstrumentParams, the one funnel that mirrors the flag onto the
|
||||
@@ -284,6 +292,11 @@ public:
|
||||
// same tick, so its arm would drain on the next one regardless).
|
||||
void flushLatencyRestart();
|
||||
|
||||
// Delivers the master-gain host notification a reload deferred (the bake's reset gain lands
|
||||
// under reloadMutex_, and performEdit may re-enter this object). Drained beside the latency
|
||||
// restart, from the same sites and for the same reason.
|
||||
void flushGainNotify();
|
||||
|
||||
// Fires a one-shot preview note-on/off through the live VoiceEngine — the same
|
||||
// noteOn/noteOff host MIDI uses, so a preview is a real voice (counts against voice
|
||||
// count, can steal/be stolen, respects Poly/Mono + Retrigger/Legato). Off the audio
|
||||
@@ -306,18 +319,33 @@ private:
|
||||
// initialize().
|
||||
void buildParameterList();
|
||||
|
||||
// The normalized value a control reads at, from the model — the projection §6.1 calls a
|
||||
// third surface. Master gain reads the processor's own atomic; everything else reads the
|
||||
// parameter set through the deck's binding.
|
||||
// THE automation read, on the audio thread, at the BLOCK BOUNDARY: the last point of each
|
||||
// queue wins. RT-safe — relaxed atomic stores only. Sample-accurate application would put a
|
||||
// per-sample "did anything change" question on the per-voice-per-sample path, which the
|
||||
// phase-wide guardrail forbids. True when at least one point landed, which is what makes the
|
||||
// merge below it conditional.
|
||||
bool drainInputParameterChanges(Steinberg::Vst::IParameterChanges* changes);
|
||||
|
||||
// The normalized value a control reads at, from the model — a projection of it, never a
|
||||
// cached shadow. Master gain reads the processor's own atomic, pitch key-track the scalar
|
||||
// beside the play bundle; everything else reads the parameter set through the deck's binding.
|
||||
double modelParamNormalized(const InstrumentParams& params,
|
||||
instrument::ui::DeckParam deck) const;
|
||||
|
||||
// Notifies the host of every exposed control whose value differs between the two parameter
|
||||
// sets. Called from setInstrumentParams — the ONE funnel every writer already goes through —
|
||||
// so no internal write can leave the host displaying, and on next touch re-imposing, a
|
||||
// superseded value. The bake's reset is the first non-gesture writer this covers.
|
||||
void notifyParamsFromModel(const InstrumentParams& before, const InstrumentParams& after);
|
||||
// The write peer of that read, and the ONE host-side write of a control's value: both the
|
||||
// controller's setParamNormalized and the audio thread's automation fold go through it, so
|
||||
// neither can miss a control whose value lives outside the parameter set.
|
||||
static void writeDeckParamToModel(InstrumentParams& params, instrument::ui::DeckParam deck,
|
||||
double normalized);
|
||||
|
||||
// Notifies the host of every exposed control whose normalized value moved. `beforeNorms` is
|
||||
// indexed by DeckParam ordinal. Called from setInstrumentParams — the ONE funnel every writer
|
||||
// already goes through — so no internal write can leave the host displaying, and on next
|
||||
// touch re-imposing, a superseded value. The bake's reset is the first non-gesture writer
|
||||
// this covers, and the reason the emission is grouped.
|
||||
void notifyParamsFromModel(const double* beforeNorms, const InstrumentParams& after);
|
||||
void notifyParamChanged(instrument::param::ParamId id, double normalized);
|
||||
bool gestureIsOpen(instrument::param::ParamId id) const;
|
||||
|
||||
// If process() published that the drain instrument is fully idle, move it into the
|
||||
// graveyard and prune — so an edited-away snapshot stops costing memory as soon as its
|
||||
@@ -376,8 +404,13 @@ private:
|
||||
|
||||
ReaperBridge bridge_;
|
||||
|
||||
// The parameter whose drag bracket is currently open, 0 for none. UI thread only.
|
||||
instrument::param::ParamId openGestureId_ = 0;
|
||||
// The parameters whose drag bracket is currently open, and whether a drag is in flight at
|
||||
// all. UI thread only. Past the cap a write degrades to a one-point gesture rather than
|
||||
// dropping — the pre-bracket behaviour, not a new failure mode.
|
||||
static constexpr std::size_t kMaxOpenGestures = 8;
|
||||
instrument::param::ParamId openGestureIds_[kMaxOpenGestures] = {};
|
||||
std::size_t openGestureCount_ = 0;
|
||||
bool gestureLatching_ = false;
|
||||
// Set across setState so a LOAD is not reflected back to the host as an edit. Main thread
|
||||
// only, and non-atomic on purpose: the SDK calls setState there and nowhere else.
|
||||
bool paramNotifySuppressed_ = false;
|
||||
@@ -387,6 +420,26 @@ private:
|
||||
// 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 block the ENGINE reads, and the ONE thing SampleData::live points at. Written only by
|
||||
// the audio thread, which merges liveParams_ with the host's automation points once per
|
||||
// block and republishes ONLY when either side moved — so a block carrying neither costs one
|
||||
// relaxed load and the engine's own read shape is unchanged. Two blocks because the seqlock's
|
||||
// single-writer contract is load-bearing and the two writers differ in thread; this
|
||||
// directory's CLAUDE.md owns the argument.
|
||||
instrument::engine::LiveParams automationLive_;
|
||||
// Audio thread only. The last liveParams_ generation merged, and the sticky automation values
|
||||
// re-applied over every merge — without them a model republish (any knob move) would revert
|
||||
// an automated parameter until its lane's next point.
|
||||
std::uint32_t seenModelGeneration_ = 0;
|
||||
static constexpr std::size_t kDeckParamSlots =
|
||||
static_cast<std::size_t>(instrument::ui::DeckParam::kCount);
|
||||
double automationNorm_[kDeckParamSlots] = {};
|
||||
bool automationHeld_[kDeckParamSlots] = {};
|
||||
// The audio thread's publication of those values to the UI thread's fold. automationAny_
|
||||
// makes the idle drain a single load.
|
||||
std::atomic<double> automationPublished_[kDeckParamSlots] = {};
|
||||
std::atomic<bool> automationPending_[kDeckParamSlots] = {};
|
||||
std::atomic<bool> automationAny_{false};
|
||||
// Serializes liveParams_.publish's two writer sites (reloadInstrument, publishLiveParams)
|
||||
// only — separate from reloadMutex_ so a knob drag's publish never blocks behind a
|
||||
// reload's WAV decode. The audio thread never takes this; process() only reads via
|
||||
@@ -435,6 +488,10 @@ private:
|
||||
// the bake's reset, which the old capture would be the wrong thing to apply it to.
|
||||
// Guarded by reloadMutex_, consumed by publishBuiltLocked.
|
||||
std::optional<double> gainAtNextPublish_;
|
||||
// Armed when that deferred gain lands, delivered by flushGainNotify once reloadMutex_ is
|
||||
// released. Same shape and same reason as latencyRestartPending_ (see it): a host handler
|
||||
// callback must never run inside this mutex.
|
||||
std::atomic<bool> gainNotifyPending_{false};
|
||||
|
||||
// The decoded PCM parked across a deactivate, so an activation cycle costs no disk read
|
||||
// and no WAV decode: activation is "the audio thread may run", not "the sample is
|
||||
|
||||
Reference in New Issue
Block a user