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:
@@ -91,7 +91,7 @@ There is no hot-reload. Copy the **Release** build's binary (`build/Release/` on
|
||||
| `src/app/` | REAPER extension entry point |
|
||||
| `src/core/audio/` | pure audio-data math |
|
||||
| `src/core/capture/` | pure logic behind the capture pillar |
|
||||
| `src/core/instrument/` | pure VST3-instrument core (bake / engine / map / note / ui) |
|
||||
| `src/core/instrument/` | pure VST3-instrument core (bake / engine / map / note / param / ui) |
|
||||
| `src/core/instrument/bake/` | the resample bake's pure half — the programmed note resolved to a frame window, the offline render over a bake-only voice engine, and the post-bake reset |
|
||||
| `src/core/instrument/engine/filter/` | pure per-voice resonant TPT/SVF filter (HP→BP→LP / HP→notch→LP morph, drive stage), run by each `Voice` between the pitch and amp stages |
|
||||
| `src/core/instrument/note/` | the programmed capture-signal model — musical divisions, tempo resolution, anchored offsets |
|
||||
|
||||
+27
-7
@@ -1270,7 +1270,13 @@ value semantics, any deck geometry, or the bake's reset *membership* (W3-T2's).
|
||||
`stepCount` is asserted **zero on all 44**.
|
||||
- **A host automation lane moving a Live parameter moves a sounding note; a lane moving a
|
||||
NoteOnLatched parameter takes effect on the next note and does NOT trigger a reload or an
|
||||
engine rebuild** — assert the tier, not just the sound.
|
||||
engine rebuild** — assert the tier, not just the sound. All three NoteOnLatched controls now
|
||||
have a delivery test in `tests/test_live_delivery.cpp` (rate, key-track, Trigger length), each
|
||||
asserting BOTH halves: the sounding note byte-identical, the next note taking the value.
|
||||
- **A host automation point's AUTHORITY IS BOUNDED**, and its release is where both review passes
|
||||
went wrong: it outranks the model only until the UI thread has folded it in and republished.
|
||||
`shell/instrument/CLAUDE.md`'s Authority section states the model, `param_merge` enforces it,
|
||||
`test_param_merge` fails if a hold is never released or is released too early.
|
||||
- **No automation path reaches `reloadInstrument` or `rebuildVoiceEngine`.**
|
||||
- **A project saved before this change opens with every parameter reading the blob's value
|
||||
and sounds identical**; a project saved by this build opens in an older binary with its
|
||||
@@ -1309,10 +1315,23 @@ value semantics, any deck geometry, or the bake's reset *membership* (W3-T2's).
|
||||
`ProcessData::inputParameterChanges` in `process()` while also implementing
|
||||
`setParamNormalized`. **Both are serviced.** This was never a DAW question — the headers
|
||||
answer it, and building on the paragraph alone is exactly what the first pass did.
|
||||
(b) The ORDERING of `setState` against the first parameter block: no longer a question. 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 — VST3's own rule. What survives as
|
||||
DAW work is recorded in `docs/TODO.md`, and none of it can change the frozen contract.
|
||||
(b) The ORDERING of `setState` against the first parameter block: no longer a question, but not
|
||||
for the reason the second pass gave. A point held by the audio thread is re-applied over every
|
||||
merge only until the UI thread has folded it into the model — the hold is bounded, and a lane
|
||||
that is genuinely DRIVING re-sends and so keeps outranking the restore, while a lane that sent
|
||||
one point and had it folded does not. That is the correct reading of the rule "a lane in
|
||||
read/write mode outranks a plug-in-side set" (stated in `docs/product/parameter-automation.md`
|
||||
§7 as reasoning from the host's replay behaviour, not from a header — the SDK does not spell it
|
||||
out). The second pass's unbounded latch made the claim true by making every later writer
|
||||
permanently deaf; `shell/instrument/CLAUDE.md`'s Authority section is the model now.
|
||||
(c) **STILL OPEN, and it is the half the bundled `[verify, FIRST]` originally asked**: that
|
||||
REAPER calls `setState` (not `setComponentState`) on a single-component plug-in — a state
|
||||
ENTRY-POINT question, not a delivery-channel one. Recorded in `docs/TODO.md` rather than closed:
|
||||
`vstsinglecomponenteffect.h:41-47` does collapse the names as §6.1 claims, our overrides land on
|
||||
the `IComponent` pair with `setEditorState`/`getEditorState` left at the base's `kNotImplemented`,
|
||||
and the blob has shipped through payload v1…v16, so the behaviour is very likely fine — but that
|
||||
is inference, not observation. The rest of the DAW work is in `docs/TODO.md` too, and none of it
|
||||
can change the frozen contract.
|
||||
- **[verify]** whether REAPER renders `ParameterInfo::units` beside the string
|
||||
`getParamStringByValue` returns, or shows the string alone. **We ship the SDK's own
|
||||
convention** — digits in the string, unit carried separately, which is what
|
||||
@@ -1328,8 +1347,9 @@ value semantics, any deck geometry, or the bake's reset *membership* (W3-T2's).
|
||||
structurally could not see it and id 1000 would have no-oped in both directions with no
|
||||
compile-time guard; both controls also had to reach the engine, which meant widening
|
||||
`LiveValues` and `foldLive`'s input and handing `Voice::start` the two latched values as
|
||||
arguments beside the rate. The guard that closes the class is `param::valueHomeFor`, asserted
|
||||
over the exposed set.
|
||||
arguments beside the rate. The guard that closes the class is `param::valueHomeFor` — asserted
|
||||
over the exposed set (every control has a home, the instance-scalar set has exactly two
|
||||
members) AND branched on by the shell's own read and write paths, so the three cannot drift.
|
||||
- **[propose at review]** whether to ship a default `IMidiMapping` CC table here or leave MIDI
|
||||
control to REAPER's host-side learn. Either is defensible; **skipping it silently is not.**
|
||||
- **[propose at review]** whether this track spends the reserved payload rung. §6.1 says
|
||||
|
||||
@@ -832,6 +832,7 @@ in the `.rpp`", and does not gain a second home for the distinction.
|
||||
2. **Whether REAPER's own per-parameter MIDI learn covers what a shipped `IMidiMapping` CC table would have.** The decision to ship no default map rests on it; if learn does not reach these parameters, a CC table is additive and frozen by nothing.
|
||||
3. **That the three migration round trips hold**: a pre-parameter project opens with every parameter reading the blob's value and sounds identical; a project saved by this build restores fully in an older binary; a project with automation drawn, saved and reopened, replays against the same plain values.
|
||||
4. **That an offline render replays automation** — the sharpest case for the audio-side drain, because the host drives `process()` and may never touch the controller.
|
||||
5. **That REAPER restores instance state through `setState`, not `setComponentState`.** This is the ENTRY-POINT half of the original bundled `[verify, FIRST]`; the pass that closed that item closed only its delivery-channel half, which is a different question. The evidence short of a DAW is strong but is inference: `vstsinglecomponenteffect.h:41-47` collapses the two names on a single-component plug-in, our `setState`/`getState` overrides land on the `IComponent` pair with `setEditorState`/`getEditorState` left at the base's `kNotImplemented`, and the blob has round-tripped through payload v1…v16 in real projects. Exercising it costs one save/reopen.
|
||||
|
||||
**Priority / risk.** Low. Nothing here is load-bearing on the frozen contract: the id table, the plain ranges and the norm↔plain laws are all decided and tested without a host.
|
||||
|
||||
|
||||
@@ -342,9 +342,15 @@ the GUI-update channel (*"should update the according GUI element(s) only"*,
|
||||
`ivsteditcontroller.h`), and `ProcessData::inputParameterChanges` is the audio-side one — the
|
||||
SDK's own `SingleComponentEffect` sample services BOTH
|
||||
(`public.sdk/samples/vst/again/source/againsimple.cpp`), and so do we. The `setState` ordering
|
||||
half dissolves with it: 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 authority rule rather than a race. **The audio thread cannot run the model path**
|
||||
half dissolves with it, but only because the hold is BOUNDED: an automation point held by the
|
||||
audio thread is re-applied over every merge until the UI thread folds it into the model, so a
|
||||
lane that is genuinely driving outranks the restore whichever way round the two arrive, while a
|
||||
lane that sent one point and had it folded does not. That is the authority rule read correctly —
|
||||
and note it is reasoning from the host's replay behaviour, not a header quote: the SDK does not
|
||||
state it. An unbounded hold makes the ordering claim true by making every later writer
|
||||
permanently deaf, which is not the same property. `shell/instrument/CLAUDE.md`'s Authority
|
||||
section is the model, and `core/instrument/param/param_merge` is where it is enforced.
|
||||
**The audio thread cannot run the model path**
|
||||
(`resolvePlay` copies velocity curves and spline contours, so it allocates), so the drain patches
|
||||
the live block in place through one pure RT-safe function whose routing is pinned by an
|
||||
exhaustive equivalence test against the model path.
|
||||
@@ -913,8 +919,12 @@ makes them automatable, since otherwise they would re-decode a WAV per automatio
|
||||
`InstrumentParams`, not `PlaySeconds`, so the host's write path could not reach it and id 1000
|
||||
would have no-oped in both directions with nothing failing to compile; both controls also had to
|
||||
reach the engine, which widened `LiveValues` and `foldLive`'s input and gave `Voice::start` the
|
||||
two latched values as arguments beside the rate. `param::valueHomeFor`, asserted over the
|
||||
exposed set, is what makes the next promotion of this shape a test failure instead of a silence.
|
||||
two latched values as arguments beside the rate. `param::valueHomeFor` is what makes the next
|
||||
promotion of this shape a test failure instead of a silence, and it earns that claim in three
|
||||
places rather than one: `test_param_live` asserts every exposed control HAS a home and that the
|
||||
instance-scalar set has exactly two members, and the shell's own read and write paths
|
||||
(`modelParamNormalized`, `writeDeckParamToModel`) now BRANCH on it rather than on a hardcoded
|
||||
control id — so a third instance scalar cannot appear without failing that count.
|
||||
|
||||
**Not promoted, and not proposed for promotion: Rate to Live.** §3.5 records the cost;
|
||||
that paragraph is the first thing to read if it is ever proposed.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -938,6 +938,106 @@ static void testAPublishedPitchOffsetRefitsThePitchEnvelopeSpan() {
|
||||
if (!(life > 11000 && life < 13000)) std::printf(" refit span: life %zu\n", life);
|
||||
}
|
||||
|
||||
// Key-track is the second member of that class, and it is a PITCH-RATIO scalar: a sounding note
|
||||
// must not be retuned by it, the next note-on must take it. Measured at a note away from the root
|
||||
// (the ratio is 1.0 at the root whatever key-track says, so the root would prove nothing).
|
||||
static void testAKeyTrackChangeSparesTheSoundingNoteAndReachesTheNextOne() {
|
||||
SampleData still = rampForReadRate();
|
||||
SampleData moved = rampForReadRate();
|
||||
LiveParams blockA, blockB;
|
||||
LiveValues halfTrack = foldLive(moved.play, moved.keyTrack);
|
||||
halfTrack.keyTrack = 0.5; // half key-tracking: an octave up reads at ratio ~1.414, not 2.0
|
||||
|
||||
const std::vector<AudioSample> baseline =
|
||||
renderPreserveCapable(still, blockA, nullptr, -1, 72);
|
||||
const std::vector<AudioSample> swept =
|
||||
renderPreserveCapable(moved, blockB, &halfTrack, 8, 72);
|
||||
CHECK(baseline.size() == swept.size());
|
||||
bool untouched = true;
|
||||
for (std::size_t i = 0; i < baseline.size() && i < swept.size(); ++i) {
|
||||
if (baseline[i] != swept[i]) { untouched = false; break; }
|
||||
}
|
||||
CHECK(untouched);
|
||||
|
||||
// The next note-on takes it, read straight off the ramp: under Varispeed the output value at
|
||||
// frame i IS the read position, so the slope over one block is the pitch ratio.
|
||||
auto slopePerFrame = [&](double keyTrack) {
|
||||
SampleData fresh = rampForReadRate();
|
||||
LiveParams block;
|
||||
fresh.live = █
|
||||
LiveValues published = foldLive(fresh.play, fresh.keyTrack);
|
||||
published.keyTrack = keyTrack;
|
||||
block.publish(published);
|
||||
VoiceEngine engine(1, fresh);
|
||||
engine.noteOn(72, 100);
|
||||
std::vector<AudioSample> out;
|
||||
engine.render(out, 512);
|
||||
return (static_cast<double>(out.back()) - static_cast<double>(out.front())) /
|
||||
static_cast<double>(out.size() - 1) * 200000.0;
|
||||
};
|
||||
// kKeyTrackDefault is 1.0 — full tracking, so an octave up reads at 2.0.
|
||||
CHECK(std::fabs(slopePerFrame(1.0) - 2.0) < 0.01);
|
||||
CHECK(std::fabs(slopePerFrame(0.5) - std::pow(2.0, 0.5)) < 0.01);
|
||||
// And the value really is carried by the BLOCK: sample.play/keyTrack never moved.
|
||||
CHECK(std::fabs(slopePerFrame(0.0) - 1.0) < 0.01);
|
||||
}
|
||||
|
||||
// Trigger length is the third: it resolves playEnd_, so it re-spans the NEXT note and leaves the
|
||||
// sounding one at the span it was struck with.
|
||||
static void testATriggerLengthChangeSparesTheSoundingNoteAndReachesTheNextOne() {
|
||||
auto triggerSource = [] {
|
||||
SampleData s = rampForReadRate();
|
||||
s.play.playMode = PlayMode::Trigger;
|
||||
s.play.trigger.lengthFraction = 1.0;
|
||||
s.play.trigAhd.holdFraction = 1.0; // flat through the span, so the span IS the lifetime
|
||||
return s;
|
||||
};
|
||||
// The note's LIFETIME is what the fraction spans, so blocks-alive measures it directly.
|
||||
auto blocksAlive = [&](double fraction) {
|
||||
SampleData fresh = triggerSource();
|
||||
LiveParams block;
|
||||
fresh.live = █
|
||||
LiveValues published = foldLive(fresh.play, fresh.keyTrack);
|
||||
published.lengthFraction = fraction;
|
||||
block.publish(published);
|
||||
VoiceEngine engine(1, fresh);
|
||||
engine.noteOn(60, 100);
|
||||
std::vector<AudioSample> out;
|
||||
int blocks = 0;
|
||||
while (engine.activeVoiceCount() > 0 && blocks < 4000) {
|
||||
engine.render(out, 512);
|
||||
++blocks;
|
||||
}
|
||||
return blocks;
|
||||
};
|
||||
const int whole = blocksAlive(1.0);
|
||||
const int quarterSpan = blocksAlive(0.25);
|
||||
CHECK(whole > 100 && whole < 4000);
|
||||
CHECK(std::fabs(static_cast<double>(quarterSpan) - 0.25 * whole) < 0.05 * whole);
|
||||
|
||||
// And the sounding note is spared. The published fraction is small enough that its span ENDS
|
||||
// inside the window rendered — asserted, not assumed, because a fraction whose playEnd_ still
|
||||
// sat past the render would leave the two runs identical whether the field were live or not.
|
||||
constexpr int kSweepBlocks = 24; // renderPreserveCapable's own loop count
|
||||
CHECK(blocksAlive(0.05) < kSweepBlocks);
|
||||
|
||||
SampleData still = triggerSource();
|
||||
SampleData moved = triggerSource();
|
||||
LiveParams blockA, blockB;
|
||||
LiveValues shortened = foldLive(moved.play, moved.keyTrack);
|
||||
shortened.lengthFraction = 0.05;
|
||||
const std::vector<AudioSample> baseline =
|
||||
renderPreserveCapable(still, blockA, nullptr, -1, 60);
|
||||
const std::vector<AudioSample> swept =
|
||||
renderPreserveCapable(moved, blockB, &shortened, 8, 60);
|
||||
CHECK(baseline.size() == swept.size());
|
||||
bool untouched = true;
|
||||
for (std::size_t i = 0; i < baseline.size() && i < swept.size(); ++i) {
|
||||
if (baseline[i] != swept[i]) { untouched = false; break; }
|
||||
}
|
||||
CHECK(untouched);
|
||||
}
|
||||
|
||||
// --- What stays latched at note-on -------------------------------------------------------
|
||||
|
||||
static void testPitchRatioAndVelocityGainStayLatched() {
|
||||
@@ -966,8 +1066,12 @@ static void testPitchRatioAndVelocityGainStayLatched() {
|
||||
|
||||
std::vector<AudioSample> out;
|
||||
LiveValues hostile = foldLive(s.play, s.keyTrack);
|
||||
// Everything the block CAN carry, moved as far as it goes. None of it names velocity, the
|
||||
// note, the pitch ratio, or the PCM — that is the property under test.
|
||||
// Everything the block CAN carry, moved as far as it goes. keyTrack and lengthFraction DO
|
||||
// name the pitch ratio and the play span — they are here precisely because a SOUNDING voice
|
||||
// must not read either, which is what makes them note-on-latched rather than live; the tests
|
||||
// above are what prove the next note does take them.
|
||||
hostile.keyTrack = 0.0;
|
||||
hostile.lengthFraction = 0.05;
|
||||
hostile.filterKeyTrack = 2.0;
|
||||
hostile.filterSettings.cutoffNorm = 0.0f;
|
||||
hostile.filterModAmount = 1.0;
|
||||
@@ -1078,6 +1182,8 @@ int main() {
|
||||
testEveryLiveFilterControlMovesTheSoundingNote();
|
||||
testOneBlockServesTwoIndependentObservers();
|
||||
testARateChangeSpareTheSoundingNoteAndReachesTheNextOne();
|
||||
testAKeyTrackChangeSparesTheSoundingNoteAndReachesTheNextOne();
|
||||
testATriggerLengthChangeSparesTheSoundingNoteAndReachesTheNextOne();
|
||||
testAPitchOffsetChangeMovesTheSoundingNoteInBothEngines();
|
||||
testAPublishedPitchOffsetLeavesTheStagedAttackWallClock();
|
||||
testAPublishedPitchOffsetRefitsThePitchEnvelopeSpan();
|
||||
|
||||
+51
-12
@@ -1,6 +1,7 @@
|
||||
// Standalone tests for the audio thread's parameter patch. The load-bearing one is the
|
||||
// EQUIVALENCE assertion: patching a control into the live block must produce, bit for bit, the
|
||||
// block the model path would have folded — which is what makes a second routing table safe.
|
||||
// Standalone tests for a host parameter write, both sides of the model/audio split. The
|
||||
// load-bearing one is the EQUIVALENCE assertion: patching a control into the live block must
|
||||
// produce exactly the block the model path would have folded after the same write — which is what
|
||||
// makes a second routing table safe.
|
||||
|
||||
#include "../src/core/instrument/param/param_live.h"
|
||||
|
||||
@@ -8,9 +9,10 @@
|
||||
#include "../src/core/instrument/param/param_units.h"
|
||||
#include "../src/core/instrument/map/sample_map.h"
|
||||
#include "../src/core/instrument/ui/deck_values.h"
|
||||
#include "../src/core/util/curve_law.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
|
||||
using namespace reasampler;
|
||||
using namespace reasampler::instrument::param;
|
||||
@@ -71,9 +73,13 @@ InstrumentParams dialledParams() {
|
||||
} // namespace
|
||||
|
||||
// THE assertion this module exists for. For every exposed control and several normalized
|
||||
// positions: writing it through the model and folding must equal patching it into the folded
|
||||
// block. Bytes, not fields — a member the patch forgot to route is caught as surely as one it
|
||||
// routed to the wrong place.
|
||||
// positions: writing it through the MODEL side of a host write and folding must equal patching it
|
||||
// into the folded block. Whole-block, not per-field — a member the patch forgot to route is
|
||||
// caught as surely as one it routed to the wrong place. Compared through live_params' own
|
||||
// field-wise operator==, NOT a memcmp: the block carries padding no copy is required to preserve,
|
||||
// so a byte compare here was non-deterministic. Both sides are the HOST's paths, which is what
|
||||
// the shell actually calls; that the host's value map agrees with the editor's everywhere it
|
||||
// should is the separate assertion below.
|
||||
static void testPatchingAControlEqualsFoldingTheModelAfterTheSameWrite() {
|
||||
const double kPositions[] = {0.0, 0.137, 0.5, 0.813, 1.0};
|
||||
for (const ParamRow& row : exposedParams()) {
|
||||
@@ -83,7 +89,7 @@ static void testPatchingAControlEqualsFoldingTheModelAfterTheSameWrite() {
|
||||
LiveValues block = modelBlock(dialledParams());
|
||||
const LiveValues before = block;
|
||||
CHECK_ID(!applyLiveParam(block, row.deck, 0.25, kRate), row.id);
|
||||
CHECK_ID(std::memcmp(&before, &block, sizeof(LiveValues)) == 0, row.id);
|
||||
CHECK_ID(before == block, row.id);
|
||||
continue;
|
||||
}
|
||||
for (double norm : kPositions) {
|
||||
@@ -91,14 +97,13 @@ static void testPatchingAControlEqualsFoldingTheModelAfterTheSameWrite() {
|
||||
if (row.deck == DeckParam::kKeyTrack) {
|
||||
written.keyTrack = reasampler::instrument::ui::keyTrackFromNorm(norm);
|
||||
} else {
|
||||
reasampler::instrument::ui::setDeckParam(row.deck, written.play, norm,
|
||||
/*segment=*/0);
|
||||
CHECK_ID(writeHostParam(row.deck, written.play, norm), row.id);
|
||||
}
|
||||
const LiveValues expected = modelBlock(written);
|
||||
|
||||
LiveValues patched = modelBlock(dialledParams());
|
||||
CHECK_ID(applyLiveParam(patched, row.deck, norm, kRate), row.id);
|
||||
CHECK_ID(std::memcmp(&expected, &patched, sizeof(LiveValues)) == 0, row.id);
|
||||
CHECK_ID(expected == patched, row.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,7 +127,7 @@ static void testAnUnexposedControlIsRefused() {
|
||||
const LiveValues before = block;
|
||||
CHECK(!applyLiveParam(block, DeckParam::kPlayMode, 1.0, kRate));
|
||||
CHECK(!applyLiveParam(block, DeckParam::kVoiceCount, 1.0, kRate));
|
||||
CHECK(std::memcmp(&before, &block, sizeof(LiveValues)) == 0);
|
||||
CHECK(before == block);
|
||||
}
|
||||
|
||||
// Every exposed control resolves to a home the host's read and write paths actually reach. The
|
||||
@@ -142,11 +147,45 @@ static void testEveryExposedControlHasAValueHome() {
|
||||
CHECK(instanceScalars == 2);
|
||||
}
|
||||
|
||||
// The host's value map is the editor's EXCEPT on the twelve curve exponents, where it skips the
|
||||
// knob detent — a drag affordance a lane has no use for and which would otherwise flatten a
|
||||
// knot-drawn near-neutral exponent to exactly 1.0 on any lane pass. Both halves are asserted: the
|
||||
// agreement everywhere else, and the difference exactly inside the detent band.
|
||||
static void testTheHostSkipsTheCurveDetentAndNothingElse() {
|
||||
using reasampler::instrument::ui::deckParamUnit;
|
||||
using reasampler::instrument::ui::storedFromNorm;
|
||||
using reasampler::instrument::ui::UnitCategory;
|
||||
const double kPositions[] = {0.0, 0.137, 0.4, 0.495, 0.5, 0.505, 0.6, 0.813, 1.0};
|
||||
for (const ParamRow& row : exposedParams()) {
|
||||
const bool exponent = deckParamUnit(row.deck) == UnitCategory::Exponent;
|
||||
for (double norm : kPositions) {
|
||||
const double editor = storedFromNorm(row.deck, norm);
|
||||
const double host = hostStoredFromNorm(row.deck, norm);
|
||||
// Inside the band but off centre is the ONE place they may differ, and must.
|
||||
const bool inBand = exponent && norm != 0.5 &&
|
||||
norm > 0.5 - reasampler::util::kCurveKnobDetent &&
|
||||
norm < 0.5 + reasampler::util::kCurveKnobDetent;
|
||||
if (inBand) {
|
||||
CHECK_ID(editor == reasampler::util::kCurveNeutral, row.id);
|
||||
CHECK_ID(host != editor, row.id);
|
||||
} else {
|
||||
CHECK_ID(host == editor, row.id);
|
||||
}
|
||||
}
|
||||
// The identity stays reachable from the host side too — that is what makes skipping the
|
||||
// detent a value-preserving change rather than a lost reset.
|
||||
if (exponent) {
|
||||
CHECK_ID(hostStoredFromNorm(row.deck, 0.5) == reasampler::util::kCurveNeutral, row.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
testPatchingAControlEqualsFoldingTheModelAfterTheSameWrite();
|
||||
testTriggerLengthIsInertUnderADrawnEnvelope();
|
||||
testAnUnexposedControlIsRefused();
|
||||
testEveryExposedControlHasAValueHome();
|
||||
testTheHostSkipsTheCurveDetentAndNothingElse();
|
||||
if (g_fail == 0) std::printf("param_live: all tests passed\n");
|
||||
return g_fail == 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
// Standalone tests for the block-boundary merge — the AUTHORITY LIFETIME of a host automation
|
||||
// point. The load-bearing one is the RELEASE: a point outranks the model only until the model
|
||||
// carries it. Held forever, one point defeats every later state restore, bake reset and knob
|
||||
// move; released too eagerly, a lane in flight reverts for a block.
|
||||
|
||||
#include "../src/core/instrument/param/param_merge.h"
|
||||
|
||||
#include "../src/core/instrument/param/param_id.h"
|
||||
#include "../src/core/instrument/param/param_live.h"
|
||||
#include "../src/core/instrument/param/param_units.h"
|
||||
#include "../src/core/instrument/map/sample_map.h"
|
||||
#include "../src/core/instrument/ui/deck_values.h"
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
|
||||
using namespace reasampler;
|
||||
using namespace reasampler::instrument::param;
|
||||
using reasampler::instrument::engine::LiveValues;
|
||||
using reasampler::instrument::engine::foldLive;
|
||||
using reasampler::instrument::map::InstrumentParams;
|
||||
using reasampler::instrument::map::resolvePlay;
|
||||
using reasampler::instrument::ui::DeckParam;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
#define CHECK_ID(cond, id) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d (param %u): %s\n", __LINE__, (id), #cond); ++g_fail; } } while(0)
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr int kRate = 48000;
|
||||
constexpr std::size_t kCutoff = static_cast<std::size_t>(DeckParam::kFilterCutoff);
|
||||
|
||||
LiveValues modelBlock(const InstrumentParams& params) {
|
||||
return foldLive(resolvePlay(params.play, kRate), params.keyTrack);
|
||||
}
|
||||
|
||||
// A slot array with one lane driving `deck`.
|
||||
struct Slots {
|
||||
AutomationSlot s[kDeckParamSlots] = {};
|
||||
AutomationSlot* operator()() { return s; }
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
// THE test this module exists for. A point lands, the merge applies it over the model; the model
|
||||
// is then rewritten to something else while the hold is STILL outstanding, and the point must win
|
||||
// — that is the ≤one-tick window the hold is for. Once the fold has caught the model up and the
|
||||
// slot is marked folded, the merge releases it and the MODEL wins, permanently.
|
||||
static void testAHeldPointOutranksTheModelOnlyUntilTheModelCarriesIt() {
|
||||
InstrumentParams automated;
|
||||
automated.play.filter.settings.cutoffNorm = 0.9f;
|
||||
|
||||
// 1. The point is held: a model that says 0.9 loses to the lane's 0.2.
|
||||
Slots slots;
|
||||
slots.s[kCutoff] = AutomationSlot{0.2, /*held=*/true, /*folded=*/false};
|
||||
LiveValues block = modelBlock(automated);
|
||||
mergeAutomation(block, slots(), kDeckParamSlots, kRate);
|
||||
CHECK(block.filterSettings.cutoffNorm == 0.2f);
|
||||
CHECK(slots.s[kCutoff].held); // still outstanding — nothing has folded it
|
||||
|
||||
// 2. A state restore lands a different value while the hold is outstanding. Still the lane's:
|
||||
// this is the window the hold exists for, and it is the ONLY window.
|
||||
InstrumentParams restored;
|
||||
restored.play.filter.settings.cutoffNorm = 0.55f;
|
||||
block = modelBlock(restored);
|
||||
mergeAutomation(block, slots(), kDeckParamSlots, kRate);
|
||||
CHECK(block.filterSettings.cutoffNorm == 0.2f);
|
||||
|
||||
// 3. The UI folds the point into the model and republishes; the merge sees the release.
|
||||
InstrumentParams folded;
|
||||
folded.play.filter.settings.cutoffNorm = 0.2f;
|
||||
slots.s[kCutoff].folded = true;
|
||||
block = modelBlock(folded);
|
||||
mergeAutomation(block, slots(), kDeckParamSlots, kRate);
|
||||
CHECK(block.filterSettings.cutoffNorm == 0.2f);
|
||||
CHECK(!slots.s[kCutoff].held); // RELEASED — this is the whole fix
|
||||
|
||||
// 4. And now a later writer — a preset load, a bake reset, a knob — actually reaches the
|
||||
// audio. This is what a latch with no release makes impossible.
|
||||
block = modelBlock(restored);
|
||||
mergeAutomation(block, slots(), kDeckParamSlots, kRate);
|
||||
CHECK(block.filterSettings.cutoffNorm == 0.55f);
|
||||
}
|
||||
|
||||
// The release must not leak across points: a lane that sent a NEW point after the fold read the
|
||||
// previous one is still driving, and its new value must survive the release of the old.
|
||||
static void testANewPointAfterTheFoldIsNotReleasedByIt() {
|
||||
Slots slots;
|
||||
// The audio thread re-holds at 0.7; the UI's fold was of the earlier 0.2, so the sequence
|
||||
// comparison the shell runs leaves `folded` false for this newer point.
|
||||
slots.s[kCutoff] = AutomationSlot{0.7, /*held=*/true, /*folded=*/false};
|
||||
InstrumentParams foldedModel;
|
||||
foldedModel.play.filter.settings.cutoffNorm = 0.2f;
|
||||
LiveValues block = modelBlock(foldedModel);
|
||||
mergeAutomation(block, slots(), kDeckParamSlots, kRate);
|
||||
CHECK(block.filterSettings.cutoffNorm == 0.7f);
|
||||
CHECK(slots.s[kCutoff].held);
|
||||
}
|
||||
|
||||
// A slot that never took a point leaves the block exactly as the model folded it.
|
||||
static void testAnUnheldSlotLeavesTheBlockAlone() {
|
||||
Slots slots;
|
||||
InstrumentParams p;
|
||||
p.play.adsr.attackSeconds = 0.25;
|
||||
const LiveValues expected = modelBlock(p);
|
||||
LiveValues block = modelBlock(p);
|
||||
mergeAutomation(block, slots(), kDeckParamSlots, kRate);
|
||||
CHECK(expected == block);
|
||||
}
|
||||
|
||||
// The dirty gate at its source. A lane resending the value it already sent — the steady state of
|
||||
// a flat segment in read mode — must report that nothing moved, so the block is never re-read,
|
||||
// re-merged or republished. A lane that MOVED must report that it did, and so must a repeat that
|
||||
// arrives after the hold was released (some other writer may have moved the model since).
|
||||
static void testARepeatOfAStandingHoldMovesNothing() {
|
||||
AutomationSlot slot{0.4, /*held=*/true, /*folded=*/false};
|
||||
CHECK(!automationPointMoves(slot, 0.4));
|
||||
CHECK(automationPointMoves(slot, 0.41));
|
||||
slot.held = false;
|
||||
CHECK(automationPointMoves(slot, 0.4));
|
||||
}
|
||||
|
||||
// The gate the merge publishes through. Two blocks folded from the same parameter set and merged
|
||||
// with the same slot state compare EQUAL — which a byte compare does not reliably report, since
|
||||
// LiveValues carries padding no copy is required to preserve. This is the assertion that fails if
|
||||
// operator== is ever "simplified" back into a memcmp.
|
||||
static void testTwoIdenticalMergesCompareEqual() {
|
||||
InstrumentParams p;
|
||||
p.play.adsr.attackSeconds = 0.13;
|
||||
p.play.trigger.lengthFraction = 0.6;
|
||||
Slots slots;
|
||||
slots.s[kCutoff] = AutomationSlot{0.4, /*held=*/true, /*folded=*/false};
|
||||
|
||||
LiveValues first = modelBlock(p);
|
||||
mergeAutomation(first, slots(), kDeckParamSlots, kRate);
|
||||
LiveValues again = modelBlock(p);
|
||||
mergeAutomation(again, slots(), kDeckParamSlots, kRate);
|
||||
CHECK(first == again);
|
||||
|
||||
slots.s[kCutoff].norm = 0.41;
|
||||
LiveValues moved = modelBlock(p);
|
||||
mergeAutomation(moved, slots(), kDeckParamSlots, kRate);
|
||||
CHECK(first != moved);
|
||||
}
|
||||
|
||||
// The merge addresses a slot by DeckParam ORDINAL, which is the one thing it does that
|
||||
// param_live's equivalence test cannot see: a slot recovered as the wrong enumerator would patch
|
||||
// a neighbouring control. Swept over every exposed control for that reason, not to re-assert the
|
||||
// value laws param_live already owns.
|
||||
static void testEverySlotResolvesToItsOwnControl() {
|
||||
const double kPositions[] = {0.0, 0.29, 0.5, 0.77, 1.0};
|
||||
for (const ParamRow& row : exposedParams()) {
|
||||
if (row.deck == DeckParam::kMasterGain) continue; // reaches the audio beside the block
|
||||
for (double norm : kPositions) {
|
||||
InstrumentParams written;
|
||||
if (row.deck == DeckParam::kKeyTrack) {
|
||||
written.keyTrack = reasampler::instrument::ui::keyTrackFromNorm(norm);
|
||||
} else {
|
||||
writeHostParam(row.deck, written.play, norm);
|
||||
}
|
||||
const LiveValues expected = modelBlock(written);
|
||||
|
||||
Slots slots;
|
||||
slots.s[static_cast<std::size_t>(row.deck)] =
|
||||
AutomationSlot{norm, /*held=*/true, /*folded=*/false};
|
||||
LiveValues merged = modelBlock(InstrumentParams{});
|
||||
mergeAutomation(merged, slots(), kDeckParamSlots, kRate);
|
||||
CHECK_ID(expected == merged, row.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
testAHeldPointOutranksTheModelOnlyUntilTheModelCarriesIt();
|
||||
testANewPointAfterTheFoldIsNotReleasedByIt();
|
||||
testAnUnheldSlotLeavesTheBlockAlone();
|
||||
testARepeatOfAStandingHoldMovesNothing();
|
||||
testTwoIdenticalMergesCompareEqual();
|
||||
testEverySlotResolvesToItsOwnControl();
|
||||
if (g_fail == 0) std::printf("param_merge: all tests passed\n");
|
||||
return g_fail == 0 ? 0 : 1;
|
||||
}
|
||||
@@ -136,6 +136,28 @@ static void testTheHostAndTheEditorAgreeOnEveryDefaultPosition() {
|
||||
}
|
||||
}
|
||||
|
||||
// The criterion names the editor's DOUBLE-CLICK, and that gesture is resetDeckParam, not
|
||||
// deckParamNorm over a default-constructed set. Asserted directly: reset a DIALLED set and its
|
||||
// stored field must read back at exactly the normalized value the host resets to. (The two
|
||||
// instance scalars have no resetDeckParam entry — the shell resets those from InstrumentParams,
|
||||
// which the test above covers at the same position.)
|
||||
static void testADoubleClickResetLandsOnTheHostsDefaultNormalized() {
|
||||
using reasampler::instrument::ui::deckParamNorm;
|
||||
using reasampler::instrument::ui::resetDeckParam;
|
||||
using reasampler::instrument::ui::setDeckParam;
|
||||
for (const ParamRow& row : exposedParams()) {
|
||||
if (valueHomeFor(row.deck) == ValueHome::InstanceScalar) continue;
|
||||
PlaySeconds dialled;
|
||||
// Away from the default first, so a reset that did nothing at all cannot pass.
|
||||
setDeckParam(row.deck, dialled, 0.37, /*segment=*/0);
|
||||
CHECK_ID(deckParamNorm(row.deck, dialled) != defaultNormalized(row.deck) ||
|
||||
defaultNormalized(row.deck) == 0.37,
|
||||
row.id);
|
||||
resetDeckParam(row.deck, dialled);
|
||||
CHECK_ID(deckParamNorm(row.deck, dialled) == defaultNormalized(row.deck), row.id);
|
||||
}
|
||||
}
|
||||
|
||||
static void testTheFiltersFourTakeTheirStoredNormVerbatim() {
|
||||
// Their stored value IS the normalized one, so no taper may participate in their default:
|
||||
// this fails the moment someone routes them through toNormalized(toPlain(x)).
|
||||
@@ -218,6 +240,7 @@ int main() {
|
||||
testEveryUnitStringAndRangeMatchesTheSpecifiedTable();
|
||||
testEveryDefaultHasAnExactNormalizedPreimage();
|
||||
testTheHostAndTheEditorAgreeOnEveryDefaultPosition();
|
||||
testADoubleClickResetLandsOnTheHostsDefaultNormalized();
|
||||
testTheFiltersFourTakeTheirStoredNormVerbatim();
|
||||
testToPlainIsMonotoneAcrossTheWholeTravel();
|
||||
testTheEndpointsAreTheDeclaredPlainRange();
|
||||
|
||||
Reference in New Issue
Block a user