Bound the automation hold to the window the model has not caught up on, and make that authority model stated, enforced and tested
This commit is contained in:
@@ -289,7 +289,7 @@ anything for a trigger shape.
|
||||
- The engine is the `sampler_core` CMake target over FOUR headers and TWO TUs, split on its own responsibility seam — cold note routing vs the hot per-sample render:
|
||||
- `play_params.h` — the value layer: `PlayParams`/`AdsrParams`/`TriggerParams`/`PitchEnvParams`/`FilterParams`, the per-instance mode enums (`ChannelMode`/`VoiceMode`/`MonoTrigger`), and `SampleData` (the ONE loaded capture: decoded PCM + root + loop + start + keyTrack + velocity curve + play params). Shared by the engine, the codec, and the editor, so a UI/codec TU reading a param struct doesn't recompile when a `Voice` member changes. `FilterParams` stores the filter module's own `FilterSettings` by value rather than a parallel copy of its normalized positions. Also the ONE home of the drawn-EG rule family — `splineActive`, `effectivePlayMode`, `enforceGateUnavailableWhileDrawn` and `effectiveLengthFraction` — all templated over the frames and seconds representations, so no consumer of either can re-read the raw fields instead.
|
||||
- `envelopes.h` — the three per-frame evaluators (`AdsrEnvelope` AHDSR, `AhdEnvelope` the sustain-less Attack/Hold/Decay, `PitchEnvelope` the AHD pitch offset), CONCRETE and fully header-inline. Never give them a common base or a virtual `tick()`: they are called per-voice-per-sample. Also home to `fitAhd`/`ahdLevelAt`, THE span split and shape every sustain-less envelope shares. A voice carries two of each shape — the amp's and the filter's — and its play mode picks which pair it reads. `AdsrEnvelope`/`PitchEnvelope` own `applyLive` (the φ-holding mid-stage rule), its fresh-note peer `snapLive`, and `StepSmoother`, the bounded offset that absorbs the level steps φ cannot cover; `AhdEnvelope` is POSITIONAL (evaluated at a source offset, not ticked), so it has no phase to hold and smooths a live reshape instead.
|
||||
- `live_params.h` / `live_params.cpp` — the live-parameter block: `LiveValues` (the plain, trivially-copyable bundle the audio thread observes), the single-writer `LiveParams` seqlock that publishes it without a lock or a torn read, `foldLive` (the ONE derivation from `PlayParams` — every publisher goes through it so the two representations cannot drift), and `ValueRamp`, the per-frame glide whose EXACT termination is what lets the filter's equality-compare cutoff skip re-engage. Links no engine: the block is a value the voice observes, not a thing the engine owns.
|
||||
- `live_params.h` / `live_params.cpp` — the live-parameter block: `LiveValues` (the plain, trivially-copyable bundle the audio thread observes), the single-writer `LiveParams` seqlock that publishes it without a lock or a torn read, `foldLive` (the ONE derivation from `PlayParams` — every publisher goes through it so the two representations cannot drift), the block's FIELD-wise `operator==` (never a memcmp — the header owns why the padding makes a byte compare report differences that do not exist), and `ValueRamp`, the per-frame glide whose EXACT termination is what lets the filter's equality-compare cutoff skip re-engage. Links no engine: the block is a value the voice observes, not a thing the engine owns.
|
||||
- `voice.h` / `voice.cpp` — one voice. The per-SAMPLE render half (`advanceFrame` and everything it calls) is INLINE IN THE HEADER by RT constraint; the per-NOTE half (note-on setup incl. the Preserve ring prime, legato retune, gate-off, the off-thread shifter presize) is out of line in the TU. The voice owns its own `VoiceFilter` and filter envelope, run between the pitch stage and the amp multiply — see `engine/filter/CLAUDE.md`. **Documented ~600-line-ceiling exception** (root `CLAUDE.md` structural heuristic 1): `voice.h` sits over the ceiling because `advanceFrame`'s RT-inline constraint forbids the seam a split would need — a documented exception, not silent overshoot.
|
||||
- `voice_engine.h` / `voice_engine.cpp` — `VoiceEngine`: note routing, bounded-stealing allocation, user-parameterized voice count (1–32, default 16), `VoiceMode` Poly/Mono (last-note held-note stack, `MonoTrigger` Retrigger/Legato), two-tier panic (CC 123 = all-notes-off release, CC 120 = immediate hard-stop including Trigger one-shots), and the block render loops. Preview injects a synthetic note-on at the loaded capture's root note into the main `VoiceEngine` — no dedicated `PreviewCard`; preview obeys polyphony/mono/voice-stealing/envelopes.
|
||||
- `engine/loop/` — the sustain loop's ONE validity/clamp fold (`resolveLoop`) plus its pre-seam crossfade geometry and the editor's default handle span; see `engine/loop/CLAUDE.md`. The voice folds it once at note-on; the crossfade weight is header-inline because it rides the per-sample read.
|
||||
|
||||
@@ -6,7 +6,11 @@
|
||||
namespace reasampler::instrument::engine {
|
||||
|
||||
LiveValues foldLive(const PlayParams& params, double keyTrack) {
|
||||
LiveValues v;
|
||||
// Value-initialized, so the padding is determinate too. Nothing reads it — the block's
|
||||
// equality is field-wise for exactly that reason — but this is the one construction site
|
||||
// every publisher goes through, and an object with indeterminate bytes travelling under a
|
||||
// seqlock is a hazard worth not having. Off the audio thread; the memset costs nothing here.
|
||||
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.
|
||||
@@ -26,6 +30,48 @@ LiveValues foldLive(const PlayParams& params, double keyTrack) {
|
||||
return v;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
bool sameAdsr(const AdsrParams& a, const AdsrParams& b) {
|
||||
return a.attackFrames == b.attackFrames && a.holdFrames == b.holdFrames &&
|
||||
a.decayFrames == b.decayFrames && a.sustainLevel == b.sustainLevel &&
|
||||
a.releaseFrames == b.releaseFrames && a.attackCurve == b.attackCurve &&
|
||||
a.decayCurve == b.decayCurve && a.releaseCurve == b.releaseCurve;
|
||||
}
|
||||
|
||||
bool sameAhd(const AhdParams& a, const AhdParams& b) {
|
||||
return a.attackFrames == b.attackFrames && a.decayFrames == b.decayFrames &&
|
||||
a.holdFraction == b.holdFraction && a.attackCurve == b.attackCurve &&
|
||||
a.decayCurve == b.decayCurve;
|
||||
}
|
||||
|
||||
bool sameFilterSettings(const filter::FilterSettings& a, const filter::FilterSettings& b) {
|
||||
return a.cutoffNorm == b.cutoffNorm && a.resonanceNorm == b.resonanceNorm &&
|
||||
a.morphNorm == b.morphNorm && a.driveNorm == b.driveNorm &&
|
||||
a.morphLaw == b.morphLaw;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool operator==(const LiveValues& a, const LiveValues& b) {
|
||||
return sameFilterSettings(a.filterSettings, b.filterSettings) &&
|
||||
a.filterModAmount == b.filterModAmount &&
|
||||
a.filterVelAmount == b.filterVelAmount &&
|
||||
a.filterKeyTrack == b.filterKeyTrack &&
|
||||
sameAdsr(a.filterEnv, b.filterEnv) &&
|
||||
sameAhd(a.filterAhd, b.filterAhd) &&
|
||||
sameAdsr(a.adsr, b.adsr) &&
|
||||
sameAhd(a.ampAhd, b.ampAhd) &&
|
||||
a.pitchEnv.enabled == b.pitchEnv.enabled &&
|
||||
a.pitchEnv.peakSemitones == b.pitchEnv.peakSemitones &&
|
||||
sameAhd(a.pitchEnv.shape, b.pitchEnv.shape) &&
|
||||
a.playRate == b.playRate &&
|
||||
a.pitchOffsetSemitones == b.pitchOffsetSemitones &&
|
||||
a.keyTrack == b.keyTrack &&
|
||||
a.lengthFraction == b.lengthFraction &&
|
||||
a.splineActive == b.splineActive;
|
||||
}
|
||||
|
||||
double liveRampStep(double sampleRate) {
|
||||
if (!(sampleRate > 0.0)) return 0.0; // also catches NaN
|
||||
return 1.0 / (kLiveRampSeconds * sampleRate);
|
||||
|
||||
@@ -71,6 +71,16 @@ struct LiveValues {
|
||||
static_assert(std::is_trivially_copyable_v<LiveValues>,
|
||||
"the live block is copied under a seqlock — it must stay a plain value");
|
||||
|
||||
// FIELD-wise equality, and it must never be "simplified" into a memcmp. LiveValues carries
|
||||
// padding, and nothing gives that padding a determinate value across a copy: NRVO is optional
|
||||
// and the implicit copy/move is specified member-wise, so two blocks folded from the same
|
||||
// parameter set are NOT reliably byte-equal. A byte compare therefore reports differences that
|
||||
// do not exist — which is exactly what it did before this existed. Listed member by member, so a
|
||||
// member added to the block above must be added here as well; this sits directly beneath the
|
||||
// struct for that reason.
|
||||
bool operator==(const LiveValues& a, const LiveValues& b);
|
||||
inline bool operator!=(const LiveValues& a, const LiveValues& b) { return !(a == b); }
|
||||
|
||||
// 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. `keyTrack` is passed in
|
||||
// because it belongs to the capture/instrument scalar beside the play bundle, not to
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
|
||||
What the instrument tells a VST3 host about its automatable parameters, with no VST3 type
|
||||
anywhere: the frozen id table, the exposed set derived from the deck's commit predicate, the
|
||||
plain-value layer (unit category, plain range, `toPlain` / `toNormalized`), and the one
|
||||
formatter per unit category. The VST3 shell (`shell/instrument/instrument_params`) adapts
|
||||
these onto `Steinberg::Vst::Parameter`; it decides nothing.
|
||||
plain-value layer (unit category, plain range, `toPlain` / `toNormalized`), the one formatter per
|
||||
unit category, the host's own norm→stored write map, and the audio thread's block-boundary merge
|
||||
decision. The VST3 shell (`shell/instrument/instrument_params`) adapts 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.
|
||||
@@ -63,13 +64,19 @@ 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.
|
||||
- `param_live` — a host parameter write, BOTH sides of the model/audio split: `applyLiveParam`
|
||||
patches the live block in place (allocation-free, lock-free, for the `IParameterChanges` queue
|
||||
the SDK delivers on the audio thread, where the model layer cannot run — `resolvePlay`
|
||||
allocates), and `writeHostParam` lands the same write in the stored parameter set. One value
|
||||
map (`param_units`' `hostStoredFromNorm`) serves both, so they cannot disagree; the ROUTING is
|
||||
pinned by an exhaustive equivalence test between them over every exposed control. The routing
|
||||
switch carries **no `default:`** — a control promoted into the list without a route fails to
|
||||
compile, which the call site's discarded return value would otherwise hide.
|
||||
- `param_merge` — the audio thread's block-boundary merge DECISION, with no atomic and no host
|
||||
type in it: which held automation points still outrank the model, which the model has caught up
|
||||
on and are released, and whether an arriving point moves anything at all. It is the testable
|
||||
half of the AUTHORITY MODEL stated in `shell/instrument/CLAUDE.md`, and the reason both of that
|
||||
model's failure modes now have a test rather than a reviewer.
|
||||
|
||||
## Gotchas
|
||||
|
||||
@@ -81,23 +88,28 @@ 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 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.
|
||||
- **A curve exponent inside the knob detent but not exactly neutral READS BACK as `1.00` on the
|
||||
host, while the stored value keeps its true exponent.** The detent lives in `curve_law`'s
|
||||
norm↔exponent map, and `toPlain` is that map — so an off-detent near-neutral exponent (an
|
||||
overlay knot drag can set one) displays as `1.00` in the host. The editor's own label reads the
|
||||
stored field and shows the true value.
|
||||
**The WRITE path does NOT have this loss, and that is deliberate.** A host write goes through
|
||||
`hostStoredFromNorm`, which skips the detent: the detent is a DRAG affordance — a drag grid
|
||||
delivers `start - dy/128` and lands on the identity only by luck, so a band wider than one drag
|
||||
step snaps to it — and a lane has no grid. `curveFromKnobNorm` already answers exactly `1.0` at
|
||||
norm `0.5`, so skipping the detent costs nothing in reachability from the host, and applying it
|
||||
would flatten a knot-drawn exponent to `1.0` on any lane pass. `test_param_live`'s
|
||||
`testTheHostSkipsTheCurveDetentAndNothingElse` pins both halves: the host map is the editor's
|
||||
everywhere else, and differs exactly inside the band.
|
||||
**What remains is the DISPLAY divergence above**, which this module already documents as
|
||||
structural and which no change to the frozen `toPlain`/`toNormalized` pair was made to chase.
|
||||
- **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.
|
||||
on purpose — norm 0 is true silence, not the floor. The formatter prints `-inf` there. The
|
||||
editor additionally SUPPRESSES its unit suffix at that one value (`editor_controls`, the
|
||||
`Decibels` + non-finite test) — "-inf" rather than "-infdB", because there is no decibel value
|
||||
there. The host has no such hook and will render `ParameterInfo::units` beside it, so this is a
|
||||
deliberate ONE-VALUE break in the "editor digits + chrome == host digits + units" invariant
|
||||
stated above.
|
||||
- **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
|
||||
|
||||
@@ -22,11 +22,17 @@ reasampler_pure_library(param_format SOURCES param_format.cpp LINK PUBLIC param_
|
||||
# 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.
|
||||
# The host write, both sides of the model/audio split: the live block patched in place and the
|
||||
# stored parameter set written, through one value map (param_units'). 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)
|
||||
LINK PUBLIC deck_values live_params param_units)
|
||||
# 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)
|
||||
# (writeHostParam -> resolvePlay -> foldLive) as its reference.
|
||||
reasampler_test(param_live LINK param_live param_id sample_map)
|
||||
|
||||
# The block-boundary merge decision — the automation hold's authority lifetime, with no atomic
|
||||
# and no host type in it.
|
||||
reasampler_pure_library(param_merge SOURCES param_merge.cpp LINK PUBLIC param_live)
|
||||
reasampler_test(param_merge LINK param_merge param_id param_units sample_map)
|
||||
|
||||
@@ -1,102 +1,123 @@
|
||||
// param_live.cpp — see param_live.h. Three field resolvers plus one dispatch; every law is
|
||||
// called, none is restated.
|
||||
// param_live.cpp — see param_live.h. ONE exhaustive routing switch and one shared value map;
|
||||
// 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)
|
||||
#include <cstdint>
|
||||
|
||||
#include "core/instrument/map/play_seconds.h" // secondsToFrames (resolvePlay's own fold)
|
||||
#include "core/instrument/param/param_units.h" // hostStoredFromNorm (the ONE host value map)
|
||||
#include "core/instrument/ui/deck_values.h" // the field resolvers setDeckParam writes through
|
||||
|
||||
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);
|
||||
const double stored = hostStoredFromNorm(deck, normalized);
|
||||
const auto frames = [&](std::int64_t& dst) {
|
||||
dst = map::secondsToFrames(stored, static_cast<double>(sampleRate));
|
||||
return true;
|
||||
};
|
||||
const auto position = [&](float& dst) { dst = static_cast<float>(stored); return true; };
|
||||
const auto value = [&](double& dst) { dst = stored; return true; };
|
||||
|
||||
// NO `default:` — see the header. A promotion that forgets this file is a compile error.
|
||||
switch (deck) {
|
||||
// The fourteen stage times: stored seconds resolved at the BUILT rate.
|
||||
case DeckParam::kAttack: return frames(block.adsr.attackFrames);
|
||||
case DeckParam::kHold: return frames(block.adsr.holdFrames);
|
||||
case DeckParam::kDecay: return frames(block.adsr.decayFrames);
|
||||
case DeckParam::kRelease: return frames(block.adsr.releaseFrames);
|
||||
case DeckParam::kTrigAttack: return frames(block.ampAhd.attackFrames);
|
||||
case DeckParam::kTrigDecay: return frames(block.ampAhd.decayFrames);
|
||||
case DeckParam::kPitchEnvAttack: return frames(block.pitchEnv.shape.attackFrames);
|
||||
case DeckParam::kPitchEnvDecay: return frames(block.pitchEnv.shape.decayFrames);
|
||||
case DeckParam::kFilterEnvAttack: return frames(block.filterEnv.attackFrames);
|
||||
case DeckParam::kFilterEnvHold: return frames(block.filterEnv.holdFrames);
|
||||
case DeckParam::kFilterEnvDecay: return frames(block.filterEnv.decayFrames);
|
||||
case DeckParam::kFilterEnvRelease: return frames(block.filterEnv.releaseFrames);
|
||||
case DeckParam::kFilterTrigAttack: return frames(block.filterAhd.attackFrames);
|
||||
case DeckParam::kFilterTrigDecay: return frames(block.filterAhd.decayFrames);
|
||||
|
||||
// The filter's four, which store their normalized position as float exactly as the
|
||||
// parameter set stores it.
|
||||
case DeckParam::kFilterMorph: return position(block.filterSettings.morphNorm);
|
||||
case DeckParam::kFilterCutoff: return position(block.filterSettings.cutoffNorm);
|
||||
case DeckParam::kFilterQ: return position(block.filterSettings.resonanceNorm);
|
||||
case DeckParam::kFilterDrive: return position(block.filterSettings.driveNorm);
|
||||
|
||||
// Everything the block carries verbatim as a double.
|
||||
case DeckParam::kSustain: return value(block.adsr.sustainLevel);
|
||||
case DeckParam::kAttackCurve: return value(block.adsr.attackCurve);
|
||||
case DeckParam::kDecayCurve: return value(block.adsr.decayCurve);
|
||||
case DeckParam::kReleaseCurve: return value(block.adsr.releaseCurve);
|
||||
case DeckParam::kTrigHold: return value(block.ampAhd.holdFraction);
|
||||
case DeckParam::kTrigAttackCurve: return value(block.ampAhd.attackCurve);
|
||||
case DeckParam::kTrigDecayCurve: return value(block.ampAhd.decayCurve);
|
||||
case DeckParam::kPitchEnvHold: return value(block.pitchEnv.shape.holdFraction);
|
||||
case DeckParam::kPitchEnvAttackCurve: return value(block.pitchEnv.shape.attackCurve);
|
||||
case DeckParam::kPitchEnvDecayCurve: return value(block.pitchEnv.shape.decayCurve);
|
||||
case DeckParam::kPitchEnvDepth: return value(block.pitchEnv.peakSemitones);
|
||||
case DeckParam::kFilterEnvSustain: return value(block.filterEnv.sustainLevel);
|
||||
case DeckParam::kFilterEnvAttackCurve: return value(block.filterEnv.attackCurve);
|
||||
case DeckParam::kFilterEnvDecayCurve: return value(block.filterEnv.decayCurve);
|
||||
case DeckParam::kFilterEnvReleaseCurve: return value(block.filterEnv.releaseCurve);
|
||||
case DeckParam::kFilterTrigHold: return value(block.filterAhd.holdFraction);
|
||||
case DeckParam::kFilterTrigAttackCurve: return value(block.filterAhd.attackCurve);
|
||||
case DeckParam::kFilterTrigDecayCurve: return value(block.filterAhd.decayCurve);
|
||||
case DeckParam::kFilterModAmt: return value(block.filterModAmount);
|
||||
case DeckParam::kFilterVel: return value(block.filterVelAmount);
|
||||
case DeckParam::kFilterKeyTrack: return value(block.filterKeyTrack);
|
||||
case DeckParam::kRate: return value(block.playRate);
|
||||
case DeckParam::kPitch: return value(block.pitchOffsetSemitones);
|
||||
case DeckParam::kKeyTrack: return value(block.keyTrack);
|
||||
|
||||
// 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.
|
||||
case DeckParam::kTrigLength:
|
||||
if (!block.splineActive) block.lengthFraction = stored;
|
||||
return true;
|
||||
|
||||
// Not carried. Master gain reaches the audio beside the block, as the processor's own
|
||||
// atomic; the rest are toggles, radios, curve-popup cells and the deck's processor-side
|
||||
// controls — all Reload- or rebuild-tier, so none of them is an exposed parameter.
|
||||
case DeckParam::kMasterGain:
|
||||
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 false;
|
||||
}
|
||||
return false; // unreachable for a valid enumerator; silences a warning.
|
||||
}
|
||||
|
||||
bool writeHostParam(DeckParam deck, map::PlaySeconds& play, double normalized) {
|
||||
const double stored = hostStoredFromNorm(deck, normalized);
|
||||
if (float* f = ui::deckFloatField(deck, play)) {
|
||||
*f = static_cast<float>(stored);
|
||||
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);
|
||||
if (double* d = ui::deckDoubleField(deck, play)) {
|
||||
*d = stored;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
// 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.
|
||||
// param_live.h — a host parameter write landed on BOTH sides of the model/audio split: into the
|
||||
// live block in place (RT-safe, for `IParameterChanges`, which the SDK delivers on the audio
|
||||
// thread where the model path cannot run — `resolvePlay` allocates), and into the stored
|
||||
// parameter set. One norm -> stored map serves both, so they cannot disagree.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "core/instrument/engine/live_params.h"
|
||||
#include "core/instrument/ui/deck_groups.h" // DeckParam
|
||||
#include "core/instrument/map/play_seconds.h" // PlaySeconds (the model-side write target)
|
||||
#include "core/instrument/ui/deck_groups.h" // DeckParam
|
||||
|
||||
namespace reasampler::instrument::param {
|
||||
|
||||
@@ -16,14 +17,25 @@ using ui::DeckParam;
|
||||
// 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.
|
||||
// The value laws are NOT restated here: `hostStoredFromNorm` is the same norm -> stored map the
|
||||
// model-side write below takes, and `map::secondsToFrames` the same fold `resolvePlay` uses. What
|
||||
// IS new is the routing — which member of the block a control names — and its switch carries no
|
||||
// `default:`, so a control promoted into the parameter list without a route here fails to COMPILE
|
||||
// rather than dropping its automation silently at a call site that discards the answer.
|
||||
//
|
||||
// `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);
|
||||
|
||||
// The MODEL-side peer: the same host write, landed in the stored parameter set instead. Sharing
|
||||
// `hostStoredFromNorm` and the field resolvers with the patch above is what makes the equivalence
|
||||
// test's claim — patch == fold-after-write — a property of one map rather than of two that agree.
|
||||
// False for a control PlaySeconds does not carry: the two instance scalars (master gain, pitch
|
||||
// key-track) are written where they live, by the shell.
|
||||
//
|
||||
// No `enforceGateUnavailableWhileDrawn` here, unlike `ui::setDeckParam`: every control that can
|
||||
// flip `splineActive` is a toggle, every toggle is Reload-tier, and no Reload-tier control is
|
||||
// exposed — so nothing reachable from a host write can open that hole.
|
||||
bool writeHostParam(DeckParam deck, map::PlaySeconds& play, double normalized);
|
||||
|
||||
} // namespace reasampler::instrument::param
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// param_merge.cpp — see param_merge.h.
|
||||
|
||||
#include "core/instrument/param/param_merge.h"
|
||||
|
||||
#include "core/instrument/param/param_live.h"
|
||||
|
||||
namespace reasampler::instrument::param {
|
||||
|
||||
void mergeAutomation(engine::LiveValues& block, AutomationSlot* slots, std::size_t count,
|
||||
int sampleRate) {
|
||||
for (std::size_t i = 0; i < count; ++i) {
|
||||
AutomationSlot& slot = slots[i];
|
||||
if (!slot.held) continue;
|
||||
if (slot.folded) {
|
||||
// Nothing to patch: `block` was read from the model, and the model is what the fold
|
||||
// wrote this point into. Dropping the hold here is the whole release.
|
||||
slot.held = false;
|
||||
slot.folded = false;
|
||||
continue;
|
||||
}
|
||||
applyLiveParam(block, static_cast<DeckParam>(i), slot.norm, sampleRate);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::param
|
||||
@@ -0,0 +1,50 @@
|
||||
// param_merge.h — the audio thread's block-boundary merge DECISION, with no host type and no
|
||||
// atomic in it: which held automation points still outrank the model, which the model has caught
|
||||
// up on and are released, and whether the result is worth republishing. The AUTHORITY MODEL it
|
||||
// implements is stated in `shell/instrument/CLAUDE.md`; this is its testable half.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
#include "core/instrument/engine/live_params.h"
|
||||
#include "core/instrument/ui/deck_groups.h" // DeckParam (the ordinal space slots are indexed by)
|
||||
|
||||
namespace reasampler::instrument::param {
|
||||
|
||||
using ui::DeckParam;
|
||||
|
||||
// The DeckParam ordinal space. One slot per control, indexed by ordinal, so a lookup is an index
|
||||
// rather than a search on the audio thread.
|
||||
inline constexpr std::size_t kDeckParamSlots = static_cast<std::size_t>(DeckParam::kCount);
|
||||
|
||||
// One control's automation state as the merge sees it.
|
||||
struct AutomationSlot {
|
||||
double norm = 0.0; // the last point this lane delivered
|
||||
bool held = false; // that point still outranks the model
|
||||
bool folded = false; // the model has since been rewritten to carry THAT point
|
||||
};
|
||||
|
||||
// Patches every still-held slot over `block`, and RELEASES each slot the model has caught up on.
|
||||
//
|
||||
// The release is what BOUNDS a point's authority. A lane outranks a plug-in-side set only while
|
||||
// it is driving; a value it delivered once, already folded back into the model, outranks nothing.
|
||||
// Without the release a single automation point would defeat every later state restore, bake
|
||||
// reset and knob move for the life of the instance — which is the failure this function exists
|
||||
// to make impossible, and which `test_param_merge` is the test of.
|
||||
//
|
||||
// RT-SAFE: no allocation, no lock, no transcendental beyond the tapers' own.
|
||||
void mergeAutomation(engine::LiveValues& block, AutomationSlot* slots, std::size_t count,
|
||||
int sampleRate);
|
||||
|
||||
// Whether a point of `normalized` for a slot in this state actually moves the block. False for a
|
||||
// point equal to a hold that is still standing — the ordinary read-mode steady state, where a
|
||||
// host delivers one point per block over a flat lane segment. Republishing there would drive
|
||||
// `VoiceEngine::refreshLive` over every sounding voice — a `std::pow`, two envelope φ re-fits and
|
||||
// the filter ramp aims, per voice — for a value that did not move. Once the hold has been
|
||||
// RELEASED the answer is true again, because some other writer may have moved the model since.
|
||||
inline bool automationPointMoves(const AutomationSlot& slot, double normalized) {
|
||||
return !slot.held || slot.norm != normalized;
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::param
|
||||
@@ -69,8 +69,11 @@ UnitKind unitKindFor(DeckParam deck) {
|
||||
// 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.
|
||||
// silently, and an exposed parameter's normalization is frozen on the first shipped
|
||||
// build — so the wrong answer would be permanent rather than correctable. THIS switch is
|
||||
// the one that has to be exhaustive; the `default:` arms further down are pre-dispatch
|
||||
// filters that fall through to it, so they inherit its exhaustiveness rather than
|
||||
// needing their own.
|
||||
case DeckParam::kFilterQ:
|
||||
case DeckParam::kFilterDrive:
|
||||
case DeckParam::kAttackCurve:
|
||||
@@ -105,6 +108,10 @@ UnitKind unitKindFor(DeckParam deck) {
|
||||
case DeckParam::kLimiterEnable:
|
||||
case DeckParam::kMasterMeter:
|
||||
case DeckParam::kMasterGr:
|
||||
return UnitKind::Dimensionless;
|
||||
// The sentinel, on its own arm: it names no control, so its unit string, plain range and
|
||||
// toPlain law are all arbitrary. It is here only because the switch is exhaustive, and
|
||||
// it stays out of the run above so that run reads as a list of real controls.
|
||||
case DeckParam::kCount:
|
||||
return UnitKind::Dimensionless;
|
||||
}
|
||||
@@ -231,6 +238,14 @@ double toNormalized(DeckParam deck, double plain) {
|
||||
return plain;
|
||||
}
|
||||
|
||||
double hostStoredFromNorm(DeckParam deck, double normalized) {
|
||||
// See the header for why the detent is a drag affordance and not part of the value law.
|
||||
if (ui::deckParamUnit(deck) == ui::UnitCategory::Exponent) {
|
||||
return util::curveFromKnobNormUndetented(normalized);
|
||||
}
|
||||
return ui::storedFromNorm(deck, normalized);
|
||||
}
|
||||
|
||||
ValueHome valueHomeFor(DeckParam deck) {
|
||||
PlaySeconds defaults;
|
||||
if (ui::deckFloatField(deck, defaults)) return ValueHome::ParamSetNorm;
|
||||
|
||||
@@ -48,6 +48,16 @@ PlainRange plainRangeFor(DeckParam deck);
|
||||
double toPlain(DeckParam deck, double normalized);
|
||||
double toNormalized(DeckParam deck, double plain);
|
||||
|
||||
// The STORED value a host write of `normalized` lands on — `ui::storedFromNorm` for every
|
||||
// control except the twelve curve exponents, where the knob law's ±0.01 detent is skipped. That
|
||||
// detent is a DRAG affordance: a drag grid lands on the identity only by luck, so a band wider
|
||||
// than one drag step snaps to it. A host lane has no grid and `curveFromKnobNorm` already
|
||||
// answers exactly 1.0 at norm 0.5, so applying the detent here would not make anything
|
||||
// reachable — it would flatten a knot-drawn near-neutral exponent to 1.0 on any lane pass.
|
||||
// BOTH host write paths take this map (the model's `writeHostParam`, the audio thread's
|
||||
// `applyLiveParam`), which is what keeps them from landing different values in the same block.
|
||||
double hostStoredFromNorm(DeckParam deck, double normalized);
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -169,11 +169,14 @@ DeckParam curveParamFor(DeckParam knob);
|
||||
// tier answers "does an edit reach the audio without a reload", not "which mechanism carries
|
||||
// it" — classifying it Reload would have said a gain move re-decodes the WAV, which it never did.
|
||||
//
|
||||
// kRate is the one NoteOnLatched control, and the reason is a real feature rather than a
|
||||
// plumbing detail: loop points and contours both scale with rate, and both are note-on folds —
|
||||
// resolveLoop runs once per note-on and a contour resolves against the note's own span. A live
|
||||
// rate would mean re-folding an already-resolved loop and re-mapping a contour mid-note without
|
||||
// a discontinuity. kPitch is not implicated and is ordinarily Live.
|
||||
// THREE controls are NoteOnLatched: kRate, kKeyTrack and kTrigLength. The exclusions list above
|
||||
// already gives the latter two their reason — both were Reload until they were promoted so they
|
||||
// could be automated at all, since a reload per automation point re-decodes the WAV. kRate's
|
||||
// reason is its own, and is a real feature rather than a plumbing detail: loop points and
|
||||
// contours both scale with rate, and both are note-on folds — resolveLoop runs once per note-on
|
||||
// and a contour resolves against the note's own span. A live rate would mean re-folding an
|
||||
// already-resolved loop and re-mapping a contour mid-note without a discontinuity. kPitch is not
|
||||
// implicated and is ordinarily Live.
|
||||
enum class LiveCommit { Live, NoteOnLatched, Reload };
|
||||
LiveCommit deckParamCommit(DeckParam id);
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ double deckParamNorm(DeckParam id, const PlaySeconds& play) {
|
||||
case DeckParam::kFilterDrive: return clamp01(play.filter.settings.driveNorm);
|
||||
case DeckParam::kFilterModAmt: return deckNormFromBipolar(play.filter.modAmount);
|
||||
case DeckParam::kFilterVel: return deckNormFromBipolar(play.filter.velAmount);
|
||||
case DeckParam::kFilterKeyTrack: return clamp01(play.filter.keyTrack / kKeyTrackMax);
|
||||
case DeckParam::kFilterKeyTrack: return keyTrackNormFrom(play.filter.keyTrack);
|
||||
case DeckParam::kFilterEnvAttack: return timeNormFromSeconds(play.filter.env.attackSeconds);
|
||||
case DeckParam::kFilterEnvHold: return timeNormFromSeconds(play.filter.env.holdSeconds);
|
||||
case DeckParam::kFilterEnvDecay: return timeNormFromSeconds(play.filter.env.decaySeconds);
|
||||
@@ -341,8 +341,8 @@ double snapDeckParamNorm(DeckParam id, double norm) {
|
||||
snapFractionToWholePercent(deckBipolarFromNorm(norm)));
|
||||
case DeckParam::kKeyTrack:
|
||||
case DeckParam::kFilterKeyTrack:
|
||||
return clamp01(
|
||||
snapFractionToWholePercent(clamp01(norm) * kKeyTrackMax) / kKeyTrackMax);
|
||||
return keyTrackNormFrom(
|
||||
snapFractionToWholePercent(keyTrackFromNorm(norm)));
|
||||
default:
|
||||
return clamp01(snapFractionToWholePercent(clamp01(norm)));
|
||||
}
|
||||
|
||||
@@ -29,10 +29,10 @@ 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.
|
||||
// The key-track norm <-> stored pair, shared by BOTH key-track controls. It gets its own home
|
||||
// because the pitch one's value lives beside the play bundle (on InstrumentParams / SampleData)
|
||||
// and so cannot ride the PlaySeconds binding below — leaving the editor knob, the host's write
|
||||
// path, the live fold and the snap to each spell the division out. Every one of them calls these.
|
||||
inline double keyTrackFromNorm(double norm) { return util::clamp01(norm) * kKeyTrackMax; }
|
||||
inline double keyTrackNormFrom(double keyTrack) { return util::clamp01(keyTrack / kKeyTrackMax); }
|
||||
|
||||
|
||||
@@ -46,11 +46,20 @@ inline double clampCurve(double exponent) {
|
||||
// and a dial swept through the centre cannot skip over it.
|
||||
inline constexpr double kCurveKnobDetent = 0.01;
|
||||
|
||||
// The same travel with the detent NOT applied — the map for a writer that has no drag grid. A
|
||||
// host automation lane delivers a NUMBER, not a gesture, so snapping it would not make the
|
||||
// identity reachable (t == 0.5 already evaluates exp(0) == 1.0 exactly here); it would only
|
||||
// destroy a near-neutral exponent the user set some other way.
|
||||
inline double curveFromKnobNormUndetented(double norm) {
|
||||
const double t = norm < 0.0 ? 0.0 : (norm > 1.0 ? 1.0 : norm);
|
||||
return clampCurve(std::exp((2.0 * t - 1.0) * std::log(kCurveMax)));
|
||||
}
|
||||
|
||||
inline double curveFromKnobNorm(double norm) {
|
||||
const double t = norm < 0.0 ? 0.0 : (norm > 1.0 ? 1.0 : norm);
|
||||
const double off = t - 0.5;
|
||||
if (off < kCurveKnobDetent && off > -kCurveKnobDetent) return kCurveNeutral;
|
||||
return clampCurve(std::exp((2.0 * t - 1.0) * std::log(kCurveMax)));
|
||||
return curveFromKnobNormUndetented(t);
|
||||
}
|
||||
|
||||
inline double knobNormFromCurve(double exponent) {
|
||||
|
||||
@@ -109,17 +109,76 @@ pure half — the frozen id table, the exposed set, the plain-value layer, the f
|
||||
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.
|
||||
writers genuinely differ in thread.
|
||||
|
||||
### THE AUTHORITY MODEL — who may write a parameter's value, and until when
|
||||
|
||||
Two passes got this subtly wrong in opposite directions (the first delivered automation on the
|
||||
wrong channel; the second made a point's authority permanent), because the model was in nobody's
|
||||
head and nowhere in the tree. It is here, and the code follows it.
|
||||
|
||||
**`ReaSamplerProcessor::params_` — plus the two instance scalars beside it — is THE model, and
|
||||
the single authority.** Everything else that holds these values is a cache or a courier:
|
||||
|
||||
| Writer | Authority begins | Authority ends |
|
||||
|---|---|---|
|
||||
| Editor gesture (`commitLive` / `commitAndReload`) | mouse-down | the commit lands in the model |
|
||||
| Host controller write (`setParamNormalized`) | the call | the call returns (it writes the model) |
|
||||
| State restore (`setState`) | the call | the call returns |
|
||||
| Bake reset (`adoptBakedCapture`) | the call | the call returns |
|
||||
| Reload seed (`reloadInstrument`) | under `reloadMutex_` | the publish (it re-folds the model) |
|
||||
| **Host automation point** (`IParameterChanges`) | the block it lands in | **the UI thread has folded it into the model and republished** |
|
||||
|
||||
Every writer except the last writes the model directly, so for those "authority ends" is just
|
||||
"the write happened". The automation lane is the only one that cannot: the SDK delivers it on the
|
||||
audio thread, where the model path allocates (`resolvePlay` copies velocity curves and spline
|
||||
contours). So it patches the engine-facing block in place and is couriered to the UI thread,
|
||||
which folds it into the model on the next tick.
|
||||
|
||||
**The hold is the bridge across that gap, and nothing more.** Between the point landing and the
|
||||
fold — at most one UI tick — the model does not yet carry the value, so a model republish in that
|
||||
window (any knob move) would revert the automated parameter until the lane's next point. The hold
|
||||
re-applies the point over every merge to stop that. The instant the model carries the value, the
|
||||
hold has no job and is **released**; from then on every writer above reaches the audio normally.
|
||||
|
||||
**Contention resolves BY RULE, not by timing.** A point outranks the model while the lane is
|
||||
driving and the model has not caught up — which is VST3's own authority rule (a lane in
|
||||
read/write mode outranks a plug-in-side set). It does NOT outrank a later restore, bake reset or
|
||||
knob move, because by then the lane is no longer driving that value; the model is.
|
||||
|
||||
**Where it is enforced, and what fails if it stops holding.**
|
||||
- The decision is the pure `core/instrument/param/param_merge`'s `mergeAutomation`;
|
||||
`tests/test_param_merge.cpp`'s
|
||||
`testAHeldPointOutranksTheModelOnlyUntilTheModelCarriesIt` is the test — it asserts both halves,
|
||||
including that a writer AFTER the release reaches the audio. A latch with no release fails it.
|
||||
- The mechanism — the per-slot sequence the audio thread stamps and the UI thread answers, and
|
||||
the acquire/release ordering that makes a release imply the publish is visible — is
|
||||
`automation_channel.h`'s, at its two methods.
|
||||
- **The release is stored LAST in `drainAutomationToModel`**, after `setInstrumentParams` and
|
||||
`publishLiveParams`. Moving it earlier reintroduces a one-block revert.
|
||||
- **`setState` therefore needs no ordering guarantee against the host's first parameter block.**
|
||||
A lane that is driving re-applies over the restore; a lane that merely sent a point once, and
|
||||
had it folded, does not — which is the correct reading of the SDK rule, and the one the second
|
||||
pass got wrong.
|
||||
|
||||
**The editor's `params_` is a CACHE of the model, authoritative for one gesture only.** A commit
|
||||
writes the WHOLE set back, and `notifyParamsFromModel` diffs it — so a stale copy would
|
||||
`performEdit` superseded values the user never touched, which a lane in latch or write mode
|
||||
records. The sync tick re-seeds it (past the drag guard) whenever `paramsGeneration_` has moved
|
||||
under it: the automation fold, the host's generic panel, a state restore.
|
||||
|
||||
**Two independent gates keep a value-identical point off the per-voice fan-out**, and they cover
|
||||
different windows: `AutomationChannel::land` drops a repeat of a standing hold whole (the flat
|
||||
read-mode segment, where a host sends one point per block), and the merge publishes only when the
|
||||
merged block differs from the last (a model republish that changed nothing). Neither is measured
|
||||
against a performance budget — they are there because `VoiceEngine::refreshLive` runs
|
||||
`voice.applyLive` over every active voice, and neither case needs it.
|
||||
|
||||
- **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
|
||||
`[verify — DAW]` REAPER's own per-parameter MIDI learn is expected to cover the case without
|
||||
@@ -155,7 +214,9 @@ pure half — the frozen id table, the exposed set, the plain-value layer, the f
|
||||
- `reasampler_embed` — implements `IReaperUIEmbedInterface` so the instrument draws inline in the TCP/MCP without a plugin-owned HWND; delegates layout to `embed_strip`. A read-only readout: the loaded capture across the keyboard span with its root marked, plus the activity level. It takes no mouse input (there is nothing on the strip to select).
|
||||
- `editor_stroke` — the editor's LICE side of the analytic stroker: builds a coverage mask with the pure `core/ui/stroke_aa` and blends it into the bitmap ONCE, writing straight to the bitmap's bits (the arithmetic matches LICE's own mode-0 combine, so a stroke composites identically to every other kit draw). Every radial and spline stroke on the editor routes through `strokeArcAA` / `strokePolylineAA` / `strokeLineAA`. Holds the draw-thread-only scratch mask and arc point list — reuse, not a hidden dependency: threading a canvas through the eight paint sites would grow those signatures to carry an allocation detail. Deliberately does NOT touch `shell/panel/draw_kit`: the waveform stroke, the docked bank panel and the browse cards are out of this seam's blast radius.
|
||||
- `instrument_bake` — the instrument's half of the resample chain, on the UI thread: render the dialed sound through the pure `core/instrument/bake` modules at the instance's PERSISTED PREVIEW VELOCITY (the velocity the user has been auditioning at — three velocity curves are live, so it is a property of the sound and not a render detail), stage the WAV OUTSIDE the bank folder, publish one `rsbake_<guid>` request, invoke the extension's landing action SYNCHRONOUSLY, read the outcome back over the same key, then adopt + reset in one act. What that key holds afterwards is classified by `core/wire`'s pure `classifyBakeAnswer`, and each of its five non-answers gets its OWN sentence — a silent no-answer stays a failure, but the user is told whether nothing wrote over the key, a stale generation was answered, the answer came in a wire this build cannot read, the request was cleared, or it was refused. All five name the key, because the extension prints one console line per key it scanned and the key is what correlates the two in a multi-instance session. None of them claims the landing never ran — nothing on this side can observe that. Two stack-RAII guards mirror `FxBypassGuard`'s discipline: the staged file and the request key are both cleared on every exit path, so a failed bake leaves no temp, no bank entry and no parameter reset. `bakeAvailable` is the affordance's paint gate. A cloned `instanceGuid` (two instances sharing one `rsbake_` key) is NOT handled here — the residual is contained by pre-existing tracking machinery instead: `planUsagePublish`'s sticky `unioned` poison plus `tiedUsageExists` (`core/tracking/tracking_authority.cpp`) force a clone's bake to `AddDistinct` rather than silently replacing a sibling's entry.
|
||||
- `instrument_params` — the VST3 adapter over `core/instrument/param`: one `Parameter` subclass whose `toPlain`/`toNormalized` ARE the taper and whose `toString` calls the one formatter, the single construction of the unit and parameter lists (ascending id, which is also the presentation order), the `setParamNormalized` projection onto the model through each control's existing commit tier, and the `beginEdit`/`performEdit`/`endEdit` notification path every internal writer reaches through `setInstrumentParams`. Decides nothing — the pure module owns the table, the laws and the formatter.
|
||||
- `instrument_params` — the VST3 adapter over `core/instrument/param`: one `Parameter` subclass whose `toPlain`/`toNormalized` ARE the taper and whose `toString` calls the one formatter, the single construction of the unit and parameter lists (ascending id, which is also the presentation order), the `setParamNormalized` projection onto the model through each control's existing commit tier, the audio thread's queue drain and the UI thread's fold + release, and the `beginEdit`/`performEdit`/`endEdit` notification path every internal writer reaches through `setInstrumentParams`. Decides nothing — the pure module owns the table, the laws, the formatter and the merge.
|
||||
- `automation_channel.h` — the host automation lane's per-instance state and the mechanism of its authority lifetime: the audio thread's hold, the per-slot sequence it stamps, the UI thread's release answer, and the acquire/release ordering that makes a release imply the model publish is visible. The MODEL it enforces is the Authority section above; the pure decision it feeds is `core/instrument/param/param_merge`. Internal to this TU family.
|
||||
- `processor_snapshot.h` — the two namespace-scope aggregates the processor hands across its thread boundary: `LoadedInstrument` (the decoded capture plus the engine playing it, swapped through the drain slot) and `MasterBusMeter` (what the audio thread publishes per block for the editor's meter). Split out of `reasampler_processor.h` on `editor_interaction.h`'s grounds — neither is behaviour.
|
||||
- `vst_entry` — VST3 entry point: `GetPluginFactory` export, class registration, channel-forked class UIDs.
|
||||
- `editor_interaction.h` — the editor's INTERACTION VOCABULARY: `DragKind` (what a gesture in flight is editing) and `HoverKind`/`HoverTarget` (what the pointer can be over). Split out of `reasampler_editor.h`, which had grown past the ~600-line ceiling with no seam — these two catalogues are produced by the input TUs and read by the paint TUs, and neither is behaviour, which is what makes them a responsibility rather than a bisection. Namespace-scope, so the editor's own members still spell them unqualified. Internal to this TU family, like `editor_internal.h`.
|
||||
- `editor_internal.h` — INTERNAL shared helpers for the `reasampler_editor` TU family, included only by the editor's own shell TUs (`editor_session` / `editor_controls` / `editor_paint_*` / `editor_input_*` / `editor_platform`), never a public seam: the `Rect`↔kit adapters, small draw primitives (knob face / title band), label helpers, the velocity-curve box derivation, and `dragModifiers()` — THE modifier read for every drag surface and gesture resolver, so the editor cannot grow a second modifier grammar — the helpers more than one band TU needs. The deck's control ids, group ids and group composition are the pure `deck_groups` module's, not this file's. The piano-strip and root-key draws live in `editor_paint_chrome`, their only consumer, not here.
|
||||
@@ -163,12 +224,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.
|
||||
- **`reasampler_processor.h` no longer needs a ceiling exception, and the one it had rested on a
|
||||
false premise.** It was described as ONE class declaration; it also carried two namespace-scope
|
||||
aggregates (`MasterBusMeter`, `LoadedInstrument`) and the automation lane's own state. Both are
|
||||
now split out — `processor_snapshot.h` and `automation_channel.h`, on the same grounds
|
||||
`editor_interaction.h` was split out of `reasampler_editor.h` in this directory: neither is
|
||||
behaviour. What remains is under the ceiling. Its bulk is the drain-slot proof and the
|
||||
RT-discipline constraints, which the comment conventions name as keep-worthy.
|
||||
|
||||
- **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
|
||||
|
||||
@@ -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_live
|
||||
param_id param_units param_format param_live param_merge
|
||||
limiter meter_accumulate meter_ballistics master_meter bake_hold
|
||||
file_bytes curve_law stroke_aa
|
||||
curve_tessellate
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// automation_channel.h — the host automation lane's per-instance state, and the ONE place its
|
||||
// AUTHORITY LIFETIME is mechanised: a point outranks the model from the block it lands in until
|
||||
// the UI thread has folded it back into the model AND republished. This directory's CLAUDE.md
|
||||
// states the model; `core/instrument/param/param_merge` is the pure decision this feeds.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
#include "core/instrument/param/param_merge.h"
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
// The DeckParam ordinal space — what the automation slots and the notification diff both index.
|
||||
inline constexpr std::size_t kDeckParamSlots = instrument::param::kDeckParamSlots;
|
||||
|
||||
class AutomationChannel {
|
||||
public:
|
||||
// --- AUDIO THREAD -------------------------------------------------------------------
|
||||
// A point landed for `slot`. `ridesTheBlock` is false for a control that reaches the audio
|
||||
// beside the live block (master gain, whose route is the processor's own atomic): such a
|
||||
// point takes no hold and does not make the block dirty, so a lane on it alone cannot drive
|
||||
// the per-voice fan-out every block for a value the block does not carry.
|
||||
//
|
||||
// Answers whether the block MOVED, which is what makes the merge conditional. A repeat of a
|
||||
// standing hold moves nothing and is dropped whole — no hold rewrite, no UI publish — because
|
||||
// it would only make the fold rewrite the model with the value already in it.
|
||||
bool land(std::size_t slot, double normalized, bool ridesTheBlock) {
|
||||
if (ridesTheBlock && !instrument::param::automationPointMoves(slots_[slot], normalized)) {
|
||||
return false;
|
||||
}
|
||||
if (ridesTheBlock) {
|
||||
slots_[slot].norm = normalized;
|
||||
slots_[slot].held = true;
|
||||
}
|
||||
published_[slot].store(normalized, std::memory_order_relaxed);
|
||||
// The sequence is stored LAST and with release: the fold reads it FIRST and only then
|
||||
// trusts the value beside it.
|
||||
seq_[slot].store(seq_[slot].load(std::memory_order_relaxed) + 1,
|
||||
std::memory_order_release);
|
||||
any_.store(true, std::memory_order_release);
|
||||
return ridesTheBlock;
|
||||
}
|
||||
|
||||
// Refreshes each held slot's release answer. Must run BEFORE the model block is read: the
|
||||
// acquire here synchronizes with the UI thread's release store, which it makes only AFTER
|
||||
// republishing the model — so a slot seen released is one whose value any block read after
|
||||
// this point is guaranteed to already carry.
|
||||
void refreshReleases() {
|
||||
for (std::size_t i = 0; i < kDeckParamSlots; ++i) {
|
||||
if (!slots_[i].held) continue;
|
||||
slots_[i].folded = folded_[i].load(std::memory_order_acquire) ==
|
||||
seq_[i].load(std::memory_order_relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
instrument::param::AutomationSlot* slots() { return slots_; }
|
||||
|
||||
// --- UI THREAD ----------------------------------------------------------------------
|
||||
// True when at least one point has landed since the last drain.
|
||||
bool takePending() { return any_.exchange(false, std::memory_order_acquire); }
|
||||
|
||||
// The value and sequence of `slot`'s unfolded point, or false when there is nothing new.
|
||||
bool takeSlot(std::size_t slot, double& value, std::uint32_t& seq) const {
|
||||
seq = seq_[slot].load(std::memory_order_acquire);
|
||||
if (seq == folded_[slot].load(std::memory_order_relaxed)) return false;
|
||||
value = published_[slot].load(std::memory_order_relaxed);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Releases `slot`'s hold. ONLY legal once the model carrying that point has been republished
|
||||
// — calling it earlier would let the audio thread drop the hold ahead of the block that
|
||||
// carries its value, which is a one-block revert to the superseded value.
|
||||
void release(std::size_t slot, std::uint32_t seq) {
|
||||
folded_[slot].store(seq, std::memory_order_release);
|
||||
}
|
||||
|
||||
private:
|
||||
instrument::param::AutomationSlot slots_[kDeckParamSlots] = {}; // audio thread only
|
||||
std::atomic<double> published_[kDeckParamSlots] = {};
|
||||
std::atomic<std::uint32_t> seq_[kDeckParamSlots] = {}; // written by the audio thread
|
||||
std::atomic<std::uint32_t> folded_[kDeckParamSlots] = {}; // written by the UI thread
|
||||
std::atomic<bool> any_{false}; // makes the UI thread's idle drain a single exchange
|
||||
};
|
||||
|
||||
// The publication atomics above are read on the audio thread; a locked implementation would be a
|
||||
// hidden mutex on it. Structural rather than assumed, for a class whose thesis is RT discipline.
|
||||
static_assert(std::atomic<double>::is_always_lock_free,
|
||||
"the automation publication must be lock-free — the audio thread writes it");
|
||||
static_assert(std::atomic<std::uint32_t>::is_always_lock_free,
|
||||
"the automation sequence must be lock-free — the audio thread writes it");
|
||||
|
||||
} // namespace reasampler::vst
|
||||
@@ -36,7 +36,6 @@ using instrument::ui::kDeckKnobSize;
|
||||
using instrument::ui::kPad;
|
||||
using instrument::ui::deckParamNorm;
|
||||
using instrument::ui::kEnvTimeMaxSeconds;
|
||||
using instrument::ui::kKeyTrackMax;
|
||||
using instrument::ui::resetDeckParam;
|
||||
using instrument::ui::sampleDeckGroups;
|
||||
using instrument::ui::setDeckParam;
|
||||
@@ -119,7 +118,7 @@ double ReaSamplerEditor::deckControlNorm(int id) const {
|
||||
if (id == kBakeHoldKnobId) return bakeHoldNorm();
|
||||
switch (static_cast<ParamControl>(id)) {
|
||||
case ParamControl::kKeyTrack:
|
||||
return clamp01(params_.keyTrack / kKeyTrackMax);
|
||||
return instrument::ui::keyTrackNormFrom(params_.keyTrack);
|
||||
case ParamControl::kVoiceCount:
|
||||
return clamp01(static_cast<double>(voiceCount_ - kMinVoiceCount) /
|
||||
static_cast<double>(kMaxVoiceCount - kMinVoiceCount));
|
||||
@@ -272,8 +271,9 @@ EnvClampBounds ReaSamplerEditor::envClampBounds() const {
|
||||
|
||||
void ReaSamplerEditor::applyParamControl(int id, double value, int segment) {
|
||||
if (id == static_cast<int>(ParamControl::kKeyTrack)) {
|
||||
// keyTrack sits beside the play bundle (0..200% over kKeyTrackMax); the knob maps 0..1.
|
||||
params_.keyTrack = clamp01(value) * kKeyTrackMax;
|
||||
// keyTrack sits beside the play bundle, so it takes deck_values' own pair rather than
|
||||
// the PlaySeconds binding.
|
||||
params_.keyTrack = instrument::ui::keyTrackFromNorm(value);
|
||||
} else {
|
||||
applyControl(id, params_.play, value, segment);
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ void ReaSamplerEditor::refreshFromBank() {
|
||||
samples_ = banksJson ? listSamples(*banksJson) : std::vector<SampleChoice>{};
|
||||
banks_ = banksJson ? listBanks(*banksJson) : std::vector<BankChoice>{};
|
||||
selectedId_ = processor_->selectedSampleId();
|
||||
params_ = processor_->instrumentParams();
|
||||
params_ = processor_->instrumentParams(seenParamsGeneration_);
|
||||
channelMode_ = processor_->channelMode();
|
||||
voiceCount_ = processor_->voiceCount();
|
||||
voiceMode_ = processor_->voiceMode();
|
||||
@@ -146,6 +146,17 @@ void ReaSamplerEditor::onSyncTimer() {
|
||||
processor_->flushGainNotify();
|
||||
processor_->drainAutomationToModel();
|
||||
|
||||
// params_ is a CACHE of the processor's model, authoritative only for the duration of a
|
||||
// gesture — which is why this sits past the drag guard. Re-seed it whenever the model has
|
||||
// moved under it: the automation fold just above, the host's own generic panel, a state
|
||||
// restore. Without this, commitLive writes the WHOLE stale set back and notifyParamsFromModel
|
||||
// diffs it as a real edit, performEdit-ing superseded values the user never touched — which a
|
||||
// lane in latch or write mode records.
|
||||
if (processor_->instrumentParamsGeneration() != seenParamsGeneration_) {
|
||||
params_ = processor_->instrumentParams(seenParamsGeneration_);
|
||||
invalidate();
|
||||
}
|
||||
|
||||
// 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.
|
||||
const bool available = bakeAvailable(processor_->bridge());
|
||||
@@ -201,6 +212,9 @@ void ReaSamplerEditor::commitAndReload() {
|
||||
if (!processor_) return;
|
||||
processor_->setSelectedSampleId(selectedId_);
|
||||
processor_->setInstrumentParams(params_);
|
||||
// This copy IS the model now, so adopt the generation it produced rather than re-seeding off
|
||||
// it on the next tick. Same reason at commitLive.
|
||||
seenParamsGeneration_ = processor_->instrumentParamsGeneration();
|
||||
processor_->reloadInstrument();
|
||||
// The reload may have auto-defaulted the channel mode (implicit only) — re-read so the
|
||||
// toggle draws what the engine actually decoded with.
|
||||
@@ -214,6 +228,7 @@ void ReaSamplerEditor::commitLive() {
|
||||
// UI thread only. See the declaration for why this still writes the parameter set.
|
||||
if (!processor_) return;
|
||||
processor_->setInstrumentParams(params_);
|
||||
seenParamsGeneration_ = processor_->instrumentParamsGeneration();
|
||||
processor_->publishLiveParams();
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
#include "core/instrument/engine/master_gain.h"
|
||||
#include "core/instrument/param/param_format.h"
|
||||
#include "core/instrument/param/param_id.h"
|
||||
#include "core/instrument/param/param_live.h" // writeHostParam (the model side of a host write)
|
||||
#include "core/instrument/param/param_units.h"
|
||||
#include "core/instrument/ui/deck_values.h"
|
||||
|
||||
@@ -133,23 +134,29 @@ tresult PLUGIN_API ReaSamplerProcessor::setParamNormalized(ParamID tag, ParamVal
|
||||
|
||||
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);
|
||||
// WHICH home a control's value has is param::valueHomeFor's answer, not a list repeated here
|
||||
// — the read peer below branches on the same predicate, the editor draws the same split at
|
||||
// applyParamControl, and the instance-scalar set's ARITY is pinned by param_live's own test,
|
||||
// so a third one cannot appear without a failure. The value map itself is param_live's, which
|
||||
// is what makes this write and the audio thread's patch of the same point agree.
|
||||
if (param::valueHomeFor(deck) == param::ValueHome::InstanceScalar) {
|
||||
// Master gain never arrives here — both callers route it to setMasterGainLinear, its own
|
||||
// funnel — so key-track is the whole of this arm.
|
||||
if (deck == DeckParam::kKeyTrack) {
|
||||
params.keyTrack = instrument::ui::keyTrackFromNorm(normalized);
|
||||
}
|
||||
return;
|
||||
}
|
||||
instrument::ui::setDeckParam(deck, params.play, normalized, /*segment=*/0);
|
||||
param::writeHostParam(deck, params.play, normalized);
|
||||
}
|
||||
|
||||
double ReaSamplerProcessor::modelParamNormalized(const InstrumentParams& params,
|
||||
DeckParam deck) const {
|
||||
if (deck == DeckParam::kMasterGain) {
|
||||
return instrument::engine::masterGainNormFromLinear(masterGainLinear());
|
||||
if (param::valueHomeFor(deck) == param::ValueHome::InstanceScalar) {
|
||||
return deck == DeckParam::kMasterGain
|
||||
? instrument::engine::masterGainNormFromLinear(masterGainLinear())
|
||||
: instrument::ui::keyTrackNormFrom(params.keyTrack);
|
||||
}
|
||||
if (deck == DeckParam::kKeyTrack) return instrument::ui::keyTrackNormFrom(params.keyTrack);
|
||||
return instrument::ui::deckParamNorm(deck, params.play);
|
||||
}
|
||||
|
||||
@@ -196,7 +203,11 @@ void ReaSamplerProcessor::notifyParamChanged(param::ParamId id, double normalize
|
||||
}
|
||||
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.
|
||||
// whole drag is one edit rather than a run of one-point ones. An UNRELATED writer that
|
||||
// reaches here mid-drag (the sync tick's flushGainNotify) latches into the same bracket
|
||||
// set, so the host sees its touch end when the drag does rather than at once — bounded by
|
||||
// the drag and correctly closed, and the alternative (a second bracket state per writer)
|
||||
// buys a distinction no host acts on.
|
||||
openGestureIds_[openGestureCount_++] = id;
|
||||
beginEdit(id);
|
||||
performEdit(id, normalized);
|
||||
@@ -244,34 +255,40 @@ bool ReaSamplerProcessor::drainInputParameterChanges(IParameterChanges* changes)
|
||||
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) {
|
||||
// automation write is the same one relaxed store the knob makes — and it takes no hold,
|
||||
// which is what keeps a lane on it alone from republishing the block every block.
|
||||
const bool ridesTheBlock = row->deck != DeckParam::kMasterGain;
|
||||
if (!ridesTheBlock) {
|
||||
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
|
||||
// Also published 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);
|
||||
landed |= automation_.land(static_cast<std::size_t>(row->deck), value, ridesTheBlock);
|
||||
}
|
||||
if (landed) automationAny_.store(true, std::memory_order_release);
|
||||
return landed;
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::drainAutomationToModel() {
|
||||
if (!automationAny_.exchange(false, std::memory_order_acquire)) return;
|
||||
if (!automation_.takePending()) return;
|
||||
InstrumentParams params = instrumentParams();
|
||||
// What was folded, and at which sequence. Held back rather than released as we go: the
|
||||
// release below is a statement that the MODEL carries the point, which is only true once the
|
||||
// publish has happened.
|
||||
std::size_t foldedSlots[kDeckParamSlots];
|
||||
std::uint32_t foldedSeqs[kDeckParamSlots];
|
||||
std::size_t foldedCount = 0;
|
||||
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);
|
||||
double value = 0.0;
|
||||
std::uint32_t seq = 0;
|
||||
if (!automation_.takeSlot(slot, value, seq)) continue;
|
||||
foldedSlots[foldedCount] = slot;
|
||||
foldedSeqs[foldedCount] = seq;
|
||||
++foldedCount;
|
||||
// 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) {
|
||||
@@ -290,6 +307,13 @@ void ReaSamplerProcessor::drainAutomationToModel() {
|
||||
}
|
||||
syncParamsFromModel();
|
||||
paramNotifySuppressed_ = wasSuppressed;
|
||||
// LAST, and that is the whole authority rule: the hold outranks the model only until the
|
||||
// model carries the point. Released any earlier and the audio thread could drop the hold
|
||||
// ahead of the block that carries its value; never released at all — the defect this
|
||||
// replaces — and one point would defeat every later restore, reset and knob move.
|
||||
for (std::size_t i = 0; i < foldedCount; ++i) {
|
||||
automation_.release(foldedSlots[i], foldedSeqs[i]);
|
||||
}
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::endParamGesture() {
|
||||
|
||||
@@ -153,13 +153,18 @@ std::string ReaSamplerProcessor::reloadInstrument() {
|
||||
// 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_;
|
||||
// BEFORE the publish: the publish is what makes the audio thread re-merge, and the
|
||||
// merge resolves every held automation stage time against this rate. Stored after,
|
||||
// a merge in the window between them would resolve them against the previous
|
||||
// capture's rate — or, on the first-ever build, against 0, where secondsToFrames
|
||||
// collapses every automated envelope stage to zero frames.
|
||||
builtSampleRate_.store(sample.sampleRate, std::memory_order_relaxed);
|
||||
{
|
||||
// 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, sample.keyTrack));
|
||||
}
|
||||
builtSampleRate_.store(sample.sampleRate, std::memory_order_relaxed);
|
||||
resolvedId = selId; // the concrete pick that resolved
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// processor_snapshot.h — the two namespace-scope aggregates the processor hands ACROSS its
|
||||
// thread boundary: the loaded instrument the audio thread renders, and the bus state it
|
||||
// publishes back for the editor's meter. Neither is behaviour, which is what makes them a
|
||||
// responsibility rather than a bisection of the processor's own declaration.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
#include "core/instrument/engine/voice_engine.h"
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
// What the audio thread publishes about the OUTPUT BUS, post-limiter, once per block. Raw
|
||||
// magnitudes only — the UI converts to dB and runs the ballistics (engine/meter_ballistics),
|
||||
// because a hold timer or a log on the audio thread would be per-block work that buys nothing.
|
||||
struct MasterBusMeter {
|
||||
float peakL = 0.f; // max |x| this block
|
||||
float peakR = 0.f;
|
||||
// Smallest gain the LIMITER computed this block (Limiter::process) — deliberately NOT
|
||||
// scaled by the transition mute, so a toggle over quiet material reads 1 (no reduction)
|
||||
// rather than the mute's own weight. 1 = no reduction.
|
||||
float minGain = 1.f;
|
||||
bool clip = false; // LATCHED at a block peak >= 0 dBFS; only clearMasterBusClip lowers it
|
||||
};
|
||||
|
||||
// The decoded capture + the voice engine playing it. The engine holds a reference to the
|
||||
// sample, so both must live/die together at a stable address — heap-allocated,
|
||||
// non-copyable, non-movable. process() only ever reads this through an atomic pointer.
|
||||
struct LoadedInstrument {
|
||||
SampleData sample;
|
||||
VoiceEngine engine;
|
||||
std::uint64_t installedAt = 0; // reloadGeneration_ at which this was installed into live_
|
||||
|
||||
// Takeover declick is on by default here (product default; the pure core defaults it
|
||||
// off): any voice restart (mono retrigger, legato, poly steal, preview) ramps instead
|
||||
// of clicking.
|
||||
LoadedInstrument(SampleData sd, std::size_t maxVoices,
|
||||
std::uint64_t gen, std::size_t preserveVoiceCap = 0,
|
||||
std::int64_t preserveWindowFrames = 0,
|
||||
VoiceMode voiceMode = VoiceMode::Poly,
|
||||
MonoTrigger monoTrigger = MonoTrigger::Retrigger)
|
||||
: sample(std::move(sd)),
|
||||
engine(maxVoices, sample, preserveVoiceCap, preserveWindowFrames,
|
||||
voiceMode, monoTrigger, /*takeoverDeclick=*/true),
|
||||
installedAt(gen) {}
|
||||
|
||||
// True when nothing in this snapshot is sounding; lets the off-thread retirer park an
|
||||
// idle drain early. Bounded scan (<= maxVoices).
|
||||
bool fullyIdle() const { return engine.activeVoiceCount() == 0; }
|
||||
|
||||
LoadedInstrument(const LoadedInstrument&) = delete;
|
||||
LoadedInstrument& operator=(const LoadedInstrument&) = delete;
|
||||
};
|
||||
|
||||
} // namespace reasampler::vst
|
||||
@@ -168,6 +168,17 @@ InstrumentParams ReaSamplerProcessor::instrumentParams() {
|
||||
return params_;
|
||||
}
|
||||
|
||||
std::uint32_t ReaSamplerProcessor::instrumentParamsGeneration() {
|
||||
std::lock_guard<std::mutex> lock(paramsMutex_);
|
||||
return paramsGeneration_;
|
||||
}
|
||||
|
||||
InstrumentParams ReaSamplerProcessor::instrumentParams(std::uint32_t& generation) {
|
||||
std::lock_guard<std::mutex> lock(paramsMutex_);
|
||||
generation = paramsGeneration_;
|
||||
return params_;
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::setInstrumentParams(const InstrumentParams& params) {
|
||||
// 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
|
||||
@@ -179,6 +190,9 @@ void ReaSamplerProcessor::setInstrumentParams(const InstrumentParams& params) {
|
||||
before[static_cast<std::size_t>(row.deck)] = modelParamNormalized(params_, row.deck);
|
||||
}
|
||||
params_ = params;
|
||||
// Bumped inside the lock with the write it names, so a reader taking the pair together
|
||||
// can never see a generation that does not describe the set beside it.
|
||||
++paramsGeneration_;
|
||||
}
|
||||
// Every writer of the parameter set — setState, the editor's commits, the bake's adopt —
|
||||
// funnels through here, so mirroring the limiter flag at this one point is what keeps the
|
||||
|
||||
@@ -420,7 +420,11 @@ private:
|
||||
std::vector<BankChoice> banks_; // the named banks, for the filter tab strip
|
||||
std::vector<SampleChoice> visible_; // samples_ narrowed by the active bank filter
|
||||
std::string selectedId_; // the loaded capture ("" = empty state)
|
||||
InstrumentParams params_; // the ONE parameter set governing it
|
||||
InstrumentParams params_; // a CACHE of the processor's model (see below)
|
||||
// The model generation params_ was taken at. That copy is authoritative only for the
|
||||
// duration of a gesture; between gestures the sync tick re-seeds it whenever this differs
|
||||
// from the processor's, because a commit writes the WHOLE set back.
|
||||
std::uint32_t seenParamsGeneration_ = 0;
|
||||
ChannelMode channelMode_ = ChannelMode::Mono; // mono/stereo toggle snapshot
|
||||
|
||||
// Mirrors of the processor's persisted voice-system params, refreshed with the rest of the
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
#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 "core/instrument/param/param_merge.h" // the block-boundary merge decision
|
||||
#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)
|
||||
|
||||
@@ -207,24 +207,31 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
|
||||
// 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_) {
|
||||
if (dirty || liveParams_.generation() != seenModelGeneration_) {
|
||||
// BEFORE the block is read, and the ordering is load-bearing — automation_channel.h
|
||||
// owns why.
|
||||
automation_.refreshReleases();
|
||||
// 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);
|
||||
// The generation ACTUALLY observed, not the one sampled above: a publish landing
|
||||
// between the two would otherwise leave this thread re-merging an identical block
|
||||
// every following quiet one.
|
||||
const std::uint32_t observed = liveParams_.read(merged);
|
||||
if (observed != 0) {
|
||||
seenModelGeneration_ = observed;
|
||||
instrument::param::mergeAutomation(
|
||||
merged, automation_.slots(), kDeckParamSlots,
|
||||
builtSampleRate_.load(std::memory_order_relaxed));
|
||||
// The second gate, and a different window from the drain's own: this one catches
|
||||
// a model republish that changed nothing (a knob committed to the value it
|
||||
// already held, a released hold whose value the model now carries). Field-wise
|
||||
// — live_params.h owns why it must never become a memcmp.
|
||||
if (!haveMergedLive_ || merged != lastMergedLive_) {
|
||||
lastMergedLive_ = merged;
|
||||
haveMergedLive_ = true;
|
||||
automationLive_.publish(merged);
|
||||
}
|
||||
automationLive_.publish(merged);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,13 +17,14 @@
|
||||
|
||||
#include "public.sdk/source/vst/vstsinglecomponenteffect.h"
|
||||
|
||||
#include "shell/instrument/automation_channel.h" // the automation hold + its release protocol
|
||||
#include "shell/instrument/processor_snapshot.h" // LoadedInstrument + MasterBusMeter
|
||||
#include "shell/instrument/reaper_bridge.h"
|
||||
#include "core/instrument/map/sample_map.h" // InstrumentParams (the one parameter set)
|
||||
#include "core/instrument/map/component_state_io.h" // ComponentState codec
|
||||
#include "core/instrument/engine/limiter.h" // the master bus's post-gain limiter
|
||||
#include "core/instrument/engine/live_params.h" // LiveParams (the live-parameter block)
|
||||
#include "core/instrument/engine/meter_accumulate.h" // the meter's block-rate folds + consume
|
||||
#include "core/instrument/engine/voice_engine.h"
|
||||
#include "core/instrument/param/param_id.h" // the frozen ParamId space + DeckParam binding
|
||||
|
||||
namespace reasampler::vst {
|
||||
@@ -36,48 +37,6 @@ using instrument::map::kPreviewVelocityDefault;
|
||||
|
||||
class ReaSamplerEmbed; // embedded TCP/MCP UI shell (owned below; see queryInterface)
|
||||
|
||||
// What the audio thread publishes about the OUTPUT BUS, post-limiter, once per block. Raw
|
||||
// magnitudes only — the UI converts to dB and runs the ballistics (engine/meter_ballistics),
|
||||
// because a hold timer or a log on the audio thread would be per-block work that buys nothing.
|
||||
struct MasterBusMeter {
|
||||
float peakL = 0.f; // max |x| this block
|
||||
float peakR = 0.f;
|
||||
// Smallest gain the LIMITER computed this block (Limiter::process) — deliberately NOT
|
||||
// scaled by the transition mute, so a toggle over quiet material reads 1 (no reduction)
|
||||
// rather than the mute's own weight. 1 = no reduction.
|
||||
float minGain = 1.f;
|
||||
bool clip = false; // LATCHED at a block peak >= 0 dBFS; only clearMasterBusClip lowers it
|
||||
};
|
||||
|
||||
// The decoded capture + the voice engine playing it. The engine holds a reference to the
|
||||
// sample, so both must live/die together at a stable address — heap-allocated,
|
||||
// non-copyable, non-movable. process() only ever reads this through an atomic pointer.
|
||||
struct LoadedInstrument {
|
||||
SampleData sample;
|
||||
VoiceEngine engine;
|
||||
std::uint64_t installedAt = 0; // reloadGeneration_ at which this was installed into live_
|
||||
|
||||
// Takeover declick is on by default here (product default; the pure core defaults it
|
||||
// off): any voice restart (mono retrigger, legato, poly steal, preview) ramps instead
|
||||
// of clicking.
|
||||
LoadedInstrument(SampleData sd, std::size_t maxVoices,
|
||||
std::uint64_t gen, std::size_t preserveVoiceCap = 0,
|
||||
std::int64_t preserveWindowFrames = 0,
|
||||
VoiceMode voiceMode = VoiceMode::Poly,
|
||||
MonoTrigger monoTrigger = MonoTrigger::Retrigger)
|
||||
: sample(std::move(sd)),
|
||||
engine(maxVoices, sample, preserveVoiceCap, preserveWindowFrames,
|
||||
voiceMode, monoTrigger, /*takeoverDeclick=*/true),
|
||||
installedAt(gen) {}
|
||||
|
||||
// True when nothing in this snapshot is sounding; lets the off-thread retirer park an
|
||||
// idle drain early. Bounded scan (<= maxVoices).
|
||||
bool fullyIdle() const { return engine.activeVoiceCount() == 0; }
|
||||
|
||||
LoadedInstrument(const LoadedInstrument&) = delete;
|
||||
LoadedInstrument& operator=(const LoadedInstrument&) = delete;
|
||||
};
|
||||
|
||||
class ReaSamplerProcessor : public Steinberg::Vst::SingleComponentEffect {
|
||||
public:
|
||||
ReaSamplerProcessor() = default;
|
||||
@@ -140,9 +99,11 @@ public:
|
||||
void syncParamsFromModel();
|
||||
|
||||
// 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.
|
||||
// the controller cache, suppressing the host notification (those values came FROM it), then
|
||||
// RELEASES each folded point's hold — which is what bounds a lane's authority to the window
|
||||
// where it is actually driving. 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. This directory's CLAUDE.md owns the authority model.
|
||||
void drainAutomationToModel();
|
||||
|
||||
// A drag's host-edit bracket, so a host in touch or latch mode records ONE continuous edit
|
||||
@@ -231,6 +192,12 @@ public:
|
||||
InstrumentParams instrumentParams();
|
||||
void setInstrumentParams(const InstrumentParams& params);
|
||||
|
||||
// The model's edit counter, bumped by every setInstrumentParams. A holder of a COPY (the
|
||||
// editor's snapshot) re-seeds when this moves under it; the overload answers both under one
|
||||
// lock, because reading them apart would let the pair disagree.
|
||||
std::uint32_t instrumentParamsGeneration();
|
||||
InstrumentParams instrumentParams(std::uint32_t& generation);
|
||||
|
||||
// Republishes the live-parameter block from the stored parameter set, resolved against the
|
||||
// rate the loaded capture was built at so an unmoved value folds to exactly the frames the
|
||||
// voices already latched. THE tier-3 commit (the three tiers are listed in this
|
||||
@@ -320,10 +287,10 @@ private:
|
||||
void buildParameterList();
|
||||
|
||||
// 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.
|
||||
// queue wins. RT-safe — relaxed/release 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 ON A CONTROL THE BLOCK
|
||||
// CARRIES, 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
|
||||
@@ -421,25 +388,22 @@ private:
|
||||
// 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.
|
||||
// the audio thread, which merges liveParams_ with the host's automation points once per block
|
||||
// and republishes ONLY when the RESULT moved — so neither an unchanged model nor a lane
|
||||
// resending the value it already sent reaches the per-voice fan-out. A block carrying no
|
||||
// automation and no model change costs one relaxed load plus, when the host passed a non-null
|
||||
// IParameterChanges (REAPER's normal case), one cross-module getParameterCount(). 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.
|
||||
// Audio thread only. The last liveParams_ generation merged, the last block published (the
|
||||
// republish gate compares against it), and the automation slots themselves.
|
||||
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};
|
||||
instrument::engine::LiveValues lastMergedLive_{};
|
||||
bool haveMergedLive_ = false;
|
||||
// The host lane's state and its release protocol; automation_channel.h owns the mechanism and
|
||||
// this directory's CLAUDE.md the authority model it enforces.
|
||||
AutomationChannel automation_;
|
||||
// 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
|
||||
@@ -509,10 +473,13 @@ private:
|
||||
std::mutex selectionMutex_;
|
||||
std::string selectedSampleId_;
|
||||
|
||||
// The one parameter set. Off-thread only; reloadInstrument bakes it into the SampleData
|
||||
// under the reload lock, never read directly on the audio thread.
|
||||
// The one parameter set — THE model, and the authority every other holder of these values
|
||||
// defers to. Off-thread only; reloadInstrument bakes it into the SampleData under the reload
|
||||
// lock, never read directly on the audio thread. paramsGeneration_ moves with every write, so
|
||||
// a holder of a copy can tell that it has.
|
||||
std::mutex paramsMutex_;
|
||||
InstrumentParams params_;
|
||||
std::uint32_t paramsGeneration_ = 0;
|
||||
|
||||
// Instance-owned sample refs: path + intrinsics per referenced sample. Refreshed
|
||||
// opportunistically from the bank blob when readable; never a bank dependency for
|
||||
|
||||
Reference in New Issue
Block a user