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); }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user