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:
2026-08-02 17:16:02 -04:00
parent de5654fb6f
commit 1fd38bbd57
35 changed files with 1155 additions and 302 deletions
+1 -1
View File
@@ -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.
+47 -1
View File
@@ -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);
+10
View File
@@ -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
+38 -26
View File
@@ -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
+11 -5
View File
@@ -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)
+109 -88
View File
@@ -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;
+22 -10
View File
@@ -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
+25
View File
@@ -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
+50
View File
@@ -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
+17 -2
View File
@@ -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;
+10
View File
@@ -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.
+8 -5
View File
@@ -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);
+3 -3
View File
@@ -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)));
}
+4 -4
View File
@@ -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); }