Bound the automation hold to the window the model has not caught up on, and make that authority model stated, enforced and tested

This commit is contained in:
2026-08-02 17:16:02 -04:00
parent de5654fb6f
commit 1fd38bbd57
35 changed files with 1155 additions and 302 deletions
+1 -1
View File
@@ -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/app/` | REAPER extension entry point |
| `src/core/audio/` | pure audio-data math | | `src/core/audio/` | pure audio-data math |
| `src/core/capture/` | pure logic behind the capture pillar | | `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/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/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 | | `src/core/instrument/note/` | the programmed capture-signal model — musical divisions, tempo resolution, anchored offsets |
+27 -7
View File
@@ -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**. `stepCount` is asserted **zero on all 44**.
- **A host automation lane moving a Live parameter moves a sounding note; a lane moving a - **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 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`.** - **No automation path reaches `reloadInstrument` or `rebuildVoiceEngine`.**
- **A project saved before this change opens with every parameter reading the blob's value - **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 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 `ProcessData::inputParameterChanges` in `process()` while also implementing
`setParamNormalized`. **Both are serviced.** This was never a DAW question — the headers `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. 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 (b) The ORDERING of `setState` against the first parameter block: no longer a question, but not
automation point held by the audio thread is re-applied over every merge, so a written lane for the reason the second pass gave. A point held by the audio thread is re-applied over every
outranks the restore whichever way round the two arrive — VST3's own rule. What survives as merge only until the UI thread has folded it into the model — the hold is bounded, and a lane
DAW work is recorded in `docs/TODO.md`, and none of it can change the frozen contract. 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 - **[verify]** whether REAPER renders `ParameterInfo::units` beside the string
`getParamStringByValue` returns, or shows the string alone. **We ship the SDK's own `getParamStringByValue` returns, or shows the string alone. **We ship the SDK's own
convention** — digits in the string, unit carried separately, which is what 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 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 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 `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 arguments beside the rate. The guard that closes the class is `param::valueHomeFor` asserted
over the exposed set. 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 - **[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.** 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 - **[propose at review]** whether this track spends the reserved payload rung. §6.1 says
+1
View File
@@ -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. 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. 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. 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. **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.
+15 -5
View File
@@ -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 `ivsteditcontroller.h`), and `ProcessData::inputParameterChanges` is the audio-side one — the
SDK's own `SingleComponentEffect` sample services BOTH SDK's own `SingleComponentEffect` sample services BOTH
(`public.sdk/samples/vst/again/source/againsimple.cpp`), and so do we. The `setState` ordering (`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 half dissolves with it, but only because the hold is BOUNDED: an automation point held by the
merge, so a written lane outranks the restore whichever way round the two arrive, which is audio thread is re-applied over every merge until the UI thread folds it into the model, so a
VST3's own authority rule rather than a race. **The audio thread cannot run the model path** 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 (`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 the live block in place through one pure RT-safe function whose routing is pinned by an
exhaustive equivalence test against the model path. 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 `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 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 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 two latched values as arguments beside the rate. `param::valueHomeFor` is what makes the next
exposed set, is what makes the next promotion of this shape a test failure instead of a silence. 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; **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. that paragraph is the first thing to read if it is ever proposed.
+1 -1
View File
@@ -289,7 +289,7 @@ anything for a trigger shape.
- The engine is the `sampler_core` CMake target over FOUR headers and TWO TUs, split on its own responsibility seam — cold note routing vs the hot per-sample render: - 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. - `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. - `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.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 (132, 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. - `voice_engine.h` / `voice_engine.cpp``VoiceEngine`: note routing, bounded-stealing allocation, user-parameterized voice count (132, 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. - `engine/loop/` — the sustain loop's ONE validity/clamp fold (`resolveLoop`) plus its pre-seam crossfade geometry and the editor's default handle span; see `engine/loop/CLAUDE.md`. The voice folds it once at note-on; the crossfade weight is header-inline because it rides the per-sample read.
+47 -1
View File
@@ -6,7 +6,11 @@
namespace reasampler::instrument::engine { namespace reasampler::instrument::engine {
LiveValues foldLive(const PlayParams& params, double keyTrack) { 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; v.keyTrack = keyTrack;
// Folded here, not at the voice: Voice::start reads the block's value directly, so the // 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. // 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; 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) { double liveRampStep(double sampleRate) {
if (!(sampleRate > 0.0)) return 0.0; // also catches NaN if (!(sampleRate > 0.0)) return 0.0; // also catches NaN
return 1.0 / (kLiveRampSeconds * sampleRate); return 1.0 / (kLiveRampSeconds * sampleRate);
+10
View File
@@ -71,6 +71,16 @@ struct LiveValues {
static_assert(std::is_trivially_copyable_v<LiveValues>, static_assert(std::is_trivially_copyable_v<LiveValues>,
"the live block is copied under a seqlock — it must stay a plain value"); "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 // 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 // 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 // because it belongs to the capture/instrument scalar beside the play bundle, not to
+38 -26
View File
@@ -4,9 +4,10 @@
What the instrument tells a VST3 host about its automatable parameters, with no VST3 type 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 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 plain-value layer (unit category, plain range, `toPlain` / `toNormalized`), the one formatter per
formatter per unit category. The VST3 shell (`shell/instrument/instrument_params`) adapts unit category, the host's own norm→stored write map, and the audio thread's block-boundary merge
these onto `Steinberg::Vst::Parameter`; it decides nothing. 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 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. 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` - `param_units``UnitKind`, `unitStringFor`, `plainRangeFor`, the `toPlain` / `toNormalized`
pair, and the defaults read off a default-constructed `PlaySeconds`. pair, and the defaults read off a default-constructed `PlaySeconds`.
- `param_format` — the eight formatters and the digits parser behind `getParamValueByString`. - `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 - `param_live`a host parameter write, BOTH sides of the model/audio split: `applyLiveParam`
place, allocation-free and lock-free, for the host's `IParameterChanges` queue. It exists patches the live block in place (allocation-free, lock-free, for the `IParameterChanges` queue
because the model layer cannot run there (`PlaySeconds` carries velocity curves and spline the SDK delivers on the audio thread, where the model layer cannot run — `resolvePlay`
contours, so `resolvePlay` allocates) while the queue is delivered there. Every law is called — allocates), and `writeHostParam` lands the same write in the stored parameter set. One value
`ui::storedFromNorm` and `map::secondsToFrames` are the same two the model path uses; what is map (`param_units`' `hostStoredFromNorm`) serves both, so they cannot disagree; the ROUTING is
new is the ROUTING, and that is pinned by an exhaustive equivalence test against the model path pinned by an exhaustive equivalence test between them over every exposed control. The routing
over every exposed control rather than by two tables that happen to agree. 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 ## Gotchas
@@ -81,23 +88,28 @@ no longer exist.
- **Round-trip exactness at arbitrary values is NOT a property here and must not be asserted.** - **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 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. 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 - **A curve exponent inside the knob detent but not exactly neutral READS BACK as `1.00` on the
touch — a VALUE consequence, not a display one.** The detent lives in `curve_law`'s host, while the stored value keeps its true exponent.** 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 norm↔exponent map, and `toPlain` is that map — so an off-detent near-neutral exponent (an
back as `1.00` (the editor's own label reads the stored field and still shows the true value). overlay knot drag can set one) displays as `1.00` in the host. The editor's own label reads the
The sharp half is the WRITE: a host write of that norm reaches `ui::setDeckParam` stored field and shows the true value.
`storedFromNorm``util::curveFromKnobNorm`, whose ±0.01 detent rewrites the stored exponent **The WRITE path does NOT have this loss, and that is deliberate.** A host write goes through
to exactly `1.0`. So an off-detent near-neutral exponent set by an overlay knot drag is `hostStoredFromNorm`, which skips the detent: the detent is a DRAG affordance — a drag grid
silently flattened by any host touch or lane pass over that parameter. delivers `start - dy/128` and lands on the identity only by luck, so a band wider than one drag
**Assessed and ACCEPTED, not merely documented:** the alternative is to widen the exposed step snaps to it — and a lane has no grid. `curveFromKnobNorm` already answers exactly `1.0` at
parameter's law so the detent band is addressable, and §6.3 freezes that law on the first norm `0.5`, so skipping the detent costs nothing in reachability from the host, and applying it
shipped build — a permanent change to twelve parameters' normalization, to preserve a would flatten a knot-drawn exponent to `1.0` on any lane pass. `test_param_live`'s
difference the user cannot see on the knob (the detent exists precisely because a drag cannot `testTheHostSkipsTheCurveDetentAndNothingElse` pins both halves: the host map is the editor's
land on the identity reliably) and cannot hear (the band is ±0.047 of the exponent). Removing everywhere else, and differs exactly inside the band.
the detent from the WRITE path alone would leave the knob unable to reach the identity, which **What remains is the DISPLAY divergence above**, which this module already documents as
is the defect it was added for. The residual is confined to knot-drawn near-neutral curves. 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 - **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 - **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` 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 *inside* `toPlain`, so `toPlain(n)` and `toPlain(double(float(n)))` are bit-identical and those
+11 -5
View File
@@ -22,11 +22,17 @@ reasampler_pure_library(param_format SOURCES param_format.cpp LINK PUBLIC param_
# lives, on InstrumentParams. # lives, on InstrumentParams.
reasampler_test(param_format LINK param_format param_id sample_map) 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 host write, both sides of the model/audio split: the live block patched in place and the
# the block is a value, not a thing the voice owns. # 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 reasampler_pure_library(param_live
SOURCES param_live.cpp 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 # sample_map for the test alone: the equivalence assertion drives the MODEL path
# (setDeckParam -> resolvePlay -> foldLive) as its reference. # (writeHostParam -> resolvePlay -> foldLive) as its reference.
reasampler_test(param_live LINK param_live param_id param_units sample_map) reasampler_test(param_live LINK param_live param_id sample_map)
# The block-boundary merge decision the automation hold's authority lifetime, with no atomic
# and no host type in it.
reasampler_pure_library(param_merge SOURCES param_merge.cpp LINK PUBLIC param_live)
reasampler_test(param_merge LINK param_merge param_id param_units sample_map)
+109 -88
View File
@@ -1,102 +1,123 @@
// param_live.cpp — see param_live.h. Three field resolvers plus one dispatch; every law is // param_live.cpp — see param_live.h. ONE exhaustive routing switch and one shared value map;
// called, none is restated. // every law is called, none is restated.
#include "core/instrument/param/param_live.h" #include "core/instrument/param/param_live.h"
#include "core/instrument/map/play_seconds.h" // secondsToFrames (resolvePlay's own fold) #include <cstdint>
#include "core/instrument/ui/deck_values.h" // storedFromNorm (setDeckParam's own map)
#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 reasampler::instrument::param {
namespace {
using engine::LiveValues; 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) { 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 const double stored = hostStoredFromNorm(deck, normalized);
// the SPLINE-FOLDED fraction, so a write while a contour is active must be inert here for const auto frames = [&](std::int64_t& dst) {
// the same reason the knob is inert in the editor. dst = map::secondsToFrames(stored, static_cast<double>(sampleRate));
if (deck == DeckParam::kTrigLength) { return true;
if (!block.splineActive) block.lengthFraction = ui::storedFromNorm(deck, normalized); };
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; return true;
} }
if (std::int64_t* f = frameField(block, deck)) { if (double* d = ui::deckDoubleField(deck, play)) {
*f = map::secondsToFrames(ui::storedFromNorm(deck, normalized), *d = stored;
static_cast<double>(sampleRate));
return true;
}
if (float* f = normField(block, deck)) {
*f = static_cast<float>(ui::storedFromNorm(deck, normalized));
return true;
}
if (double* f = doubleField(block, deck)) {
*f = ui::storedFromNorm(deck, normalized);
return true; return true;
} }
return false; return false;
+22 -10
View File
@@ -1,12 +1,13 @@
// param_live.h — the AUDIO-THREAD half of a host parameter write: one exposed control patched // param_live.h — a host parameter write landed on BOTH sides of the model/audio split: into the
// into the live block, in place, with no allocation and no lock. It exists because the model // live block in place (RT-safe, for `IParameterChanges`, which the SDK delivers on the audio
// layer cannot run on the audio thread (PlaySeconds carries velocity curves and spline contours, // thread where the model path cannot run — `resolvePlay` allocates), and into the stored
// so resolvePlay allocates), while `IParameterChanges` is delivered there. // parameter set. One norm -> stored map serves both, so they cannot disagree.
#pragma once #pragma once
#include "core/instrument/engine/live_params.h" #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 { 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, // 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. // 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 // The value laws are NOT restated here: `hostStoredFromNorm` is the same norm -> stored map the
// `setDeckParam` writes with, and `map::secondsToFrames` the same fold `resolvePlay` uses. What // 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 that is pinned by an // IS new is the routing — which member of the block a control names — and its switch carries no
// exhaustive equivalence test against the model path over every exposed control, rather than by // `default:`, so a control promoted into the parameter list without a route here fails to COMPILE
// two tables that happen to agree. // 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_), // `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. // 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); 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 } // namespace reasampler::instrument::param
+25
View File
@@ -0,0 +1,25 @@
// param_merge.cpp — see param_merge.h.
#include "core/instrument/param/param_merge.h"
#include "core/instrument/param/param_live.h"
namespace reasampler::instrument::param {
void mergeAutomation(engine::LiveValues& block, AutomationSlot* slots, std::size_t count,
int sampleRate) {
for (std::size_t i = 0; i < count; ++i) {
AutomationSlot& slot = slots[i];
if (!slot.held) continue;
if (slot.folded) {
// Nothing to patch: `block` was read from the model, and the model is what the fold
// wrote this point into. Dropping the hold here is the whole release.
slot.held = false;
slot.folded = false;
continue;
}
applyLiveParam(block, static_cast<DeckParam>(i), slot.norm, sampleRate);
}
}
} // namespace reasampler::instrument::param
+50
View File
@@ -0,0 +1,50 @@
// param_merge.h — the audio thread's block-boundary merge DECISION, with no host type and no
// atomic in it: which held automation points still outrank the model, which the model has caught
// up on and are released, and whether the result is worth republishing. The AUTHORITY MODEL it
// implements is stated in `shell/instrument/CLAUDE.md`; this is its testable half.
#pragma once
#include <cstddef>
#include "core/instrument/engine/live_params.h"
#include "core/instrument/ui/deck_groups.h" // DeckParam (the ordinal space slots are indexed by)
namespace reasampler::instrument::param {
using ui::DeckParam;
// The DeckParam ordinal space. One slot per control, indexed by ordinal, so a lookup is an index
// rather than a search on the audio thread.
inline constexpr std::size_t kDeckParamSlots = static_cast<std::size_t>(DeckParam::kCount);
// One control's automation state as the merge sees it.
struct AutomationSlot {
double norm = 0.0; // the last point this lane delivered
bool held = false; // that point still outranks the model
bool folded = false; // the model has since been rewritten to carry THAT point
};
// Patches every still-held slot over `block`, and RELEASES each slot the model has caught up on.
//
// The release is what BOUNDS a point's authority. A lane outranks a plug-in-side set only while
// it is driving; a value it delivered once, already folded back into the model, outranks nothing.
// Without the release a single automation point would defeat every later state restore, bake
// reset and knob move for the life of the instance — which is the failure this function exists
// to make impossible, and which `test_param_merge` is the test of.
//
// RT-SAFE: no allocation, no lock, no transcendental beyond the tapers' own.
void mergeAutomation(engine::LiveValues& block, AutomationSlot* slots, std::size_t count,
int sampleRate);
// Whether a point of `normalized` for a slot in this state actually moves the block. False for a
// point equal to a hold that is still standing — the ordinary read-mode steady state, where a
// host delivers one point per block over a flat lane segment. Republishing there would drive
// `VoiceEngine::refreshLive` over every sounding voice — a `std::pow`, two envelope φ re-fits and
// the filter ramp aims, per voice — for a value that did not move. Once the hold has been
// RELEASED the answer is true again, because some other writer may have moved the model since.
inline bool automationPointMoves(const AutomationSlot& slot, double normalized) {
return !slot.held || slot.norm != normalized;
}
} // namespace reasampler::instrument::param
+17 -2
View File
@@ -69,8 +69,11 @@ UnitKind unitKindFor(DeckParam deck) {
// The twelve curve exponents and the filter's two dimensionless tone controls. Listed // 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 // 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 // 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 // silently, and an exposed parameter's normalization is frozen on the first shipped
// build — so the wrong answer would be permanent rather than correctable. // 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::kFilterQ:
case DeckParam::kFilterDrive: case DeckParam::kFilterDrive:
case DeckParam::kAttackCurve: case DeckParam::kAttackCurve:
@@ -105,6 +108,10 @@ UnitKind unitKindFor(DeckParam deck) {
case DeckParam::kLimiterEnable: case DeckParam::kLimiterEnable:
case DeckParam::kMasterMeter: case DeckParam::kMasterMeter:
case DeckParam::kMasterGr: 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: case DeckParam::kCount:
return UnitKind::Dimensionless; return UnitKind::Dimensionless;
} }
@@ -231,6 +238,14 @@ double toNormalized(DeckParam deck, double plain) {
return 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) { ValueHome valueHomeFor(DeckParam deck) {
PlaySeconds defaults; PlaySeconds defaults;
if (ui::deckFloatField(deck, defaults)) return ValueHome::ParamSetNorm; if (ui::deckFloatField(deck, defaults)) return ValueHome::ParamSetNorm;
+10
View File
@@ -48,6 +48,16 @@ PlainRange plainRangeFor(DeckParam deck);
double toPlain(DeckParam deck, double normalized); double toPlain(DeckParam deck, double normalized);
double toNormalized(DeckParam deck, double plain); 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 // 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 // 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. // otherwise no-op silently in BOTH directions, with nothing to catch it at compile time.
+8 -5
View File
@@ -169,11 +169,14 @@ DeckParam curveParamFor(DeckParam knob);
// tier answers "does an edit reach the audio without a reload", not "which mechanism carries // 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. // 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 // THREE controls are NoteOnLatched: kRate, kKeyTrack and kTrigLength. The exclusions list above
// plumbing detail: loop points and contours both scale with rate, and both are note-on folds — // already gives the latter two their reason — both were Reload until they were promoted so they
// resolveLoop runs once per note-on and a contour resolves against the note's own span. A live // could be automated at all, since a reload per automation point re-decodes the WAV. kRate's
// rate would mean re-folding an already-resolved loop and re-mapping a contour mid-note without // reason is its own, and is a real feature rather than a plumbing detail: loop points and
// a discontinuity. kPitch is not implicated and is ordinarily Live. // 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 }; enum class LiveCommit { Live, NoteOnLatched, Reload };
LiveCommit deckParamCommit(DeckParam id); LiveCommit deckParamCommit(DeckParam id);
+3 -3
View File
@@ -63,7 +63,7 @@ double deckParamNorm(DeckParam id, const PlaySeconds& play) {
case DeckParam::kFilterDrive: return clamp01(play.filter.settings.driveNorm); case DeckParam::kFilterDrive: return clamp01(play.filter.settings.driveNorm);
case DeckParam::kFilterModAmt: return deckNormFromBipolar(play.filter.modAmount); case DeckParam::kFilterModAmt: return deckNormFromBipolar(play.filter.modAmount);
case DeckParam::kFilterVel: return deckNormFromBipolar(play.filter.velAmount); 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::kFilterEnvAttack: return timeNormFromSeconds(play.filter.env.attackSeconds);
case DeckParam::kFilterEnvHold: return timeNormFromSeconds(play.filter.env.holdSeconds); case DeckParam::kFilterEnvHold: return timeNormFromSeconds(play.filter.env.holdSeconds);
case DeckParam::kFilterEnvDecay: return timeNormFromSeconds(play.filter.env.decaySeconds); case DeckParam::kFilterEnvDecay: return timeNormFromSeconds(play.filter.env.decaySeconds);
@@ -341,8 +341,8 @@ double snapDeckParamNorm(DeckParam id, double norm) {
snapFractionToWholePercent(deckBipolarFromNorm(norm))); snapFractionToWholePercent(deckBipolarFromNorm(norm)));
case DeckParam::kKeyTrack: case DeckParam::kKeyTrack:
case DeckParam::kFilterKeyTrack: case DeckParam::kFilterKeyTrack:
return clamp01( return keyTrackNormFrom(
snapFractionToWholePercent(clamp01(norm) * kKeyTrackMax) / kKeyTrackMax); snapFractionToWholePercent(keyTrackFromNorm(norm)));
default: default:
return clamp01(snapFractionToWholePercent(clamp01(norm))); return clamp01(snapFractionToWholePercent(clamp01(norm)));
} }
+4 -4
View File
@@ -29,10 +29,10 @@ inline constexpr double kPitchDepthMaxSemis = kVelocityPitchRangeSemitones;
// Key-track knob ceiling (0..200%), shared by the pitch and filter key-track controls. // Key-track knob ceiling (0..200%), shared by the pitch and filter key-track controls.
inline constexpr double kKeyTrackMax = 2.0; inline constexpr double kKeyTrackMax = 2.0;
// The pitch key-track scalar lives beside the play bundle (on InstrumentParams / SampleData), // The key-track norm <-> stored pair, shared by BOTH key-track controls. It gets its own home
// so its two conversions cannot ride the PlaySeconds binding below. One home for them anyway: // because the pitch one's value lives beside the play bundle (on InstrumentParams / SampleData)
// the editor knob, the host's write path and the live fold would otherwise each spell the // and so cannot ride the PlaySeconds binding below — leaving the editor knob, the host's write
// division out. // 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 keyTrackFromNorm(double norm) { return util::clamp01(norm) * kKeyTrackMax; }
inline double keyTrackNormFrom(double keyTrack) { return util::clamp01(keyTrack / kKeyTrackMax); } inline double keyTrackNormFrom(double keyTrack) { return util::clamp01(keyTrack / kKeyTrackMax); }
+10 -1
View File
@@ -46,11 +46,20 @@ inline double clampCurve(double exponent) {
// and a dial swept through the centre cannot skip over it. // and a dial swept through the centre cannot skip over it.
inline constexpr double kCurveKnobDetent = 0.01; 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) { inline double curveFromKnobNorm(double norm) {
const double t = norm < 0.0 ? 0.0 : (norm > 1.0 ? 1.0 : norm); const double t = norm < 0.0 ? 0.0 : (norm > 1.0 ? 1.0 : norm);
const double off = t - 0.5; const double off = t - 0.5;
if (off < kCurveKnobDetent && off > -kCurveKnobDetent) return kCurveNeutral; 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) { inline double knobNormFromCurve(double exponent) {
+75 -13
View File
@@ -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 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 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 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 writers genuinely differ in thread.
block carrying neither costs one relaxed load and the engine's read shape is unchanged.
### 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`, - **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 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 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 from notifying the host — the values came FROM it, and echoing them would let a lane in write
mode re-record its own playback. 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 - **`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 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 `[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). - `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. - `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_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. - `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_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. - `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 ## Gotchas
- **`reasampler_processor.h` is a documented ~600-line-ceiling exception** (root `CLAUDE.md`, - **`reasampler_processor.h` no longer needs a ceiling exception, and the one it had rested on a
structural heuristic 1), on the same footing as `voice.h`'s: it is ONE class declaration, so false premise.** It was described as ONE class declaration; it also carried two namespace-scope
the seam the heuristic asks for does not exist — a split would be an arbitrary bisection, and aggregates (`MasterBusMeter`, `LoadedInstrument`) and the automation lane's own state. Both are
the implementation is already split across three TUs on its real seams. Its bulk is the now split out — `processor_snapshot.h` and `automation_channel.h`, on the same grounds
drain-slot proof, the RT-discipline constraints and the two-block automation contract, all of `editor_interaction.h` was split out of `reasampler_editor.h` in this directory: neither is
which the comment conventions name as keep-worthy. Not silent overshoot. 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 - **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 `Main_OnCommandEx` inline from `WM_LBUTTONDOWN` would run the extension's whole landing
+1 -1
View File
@@ -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 waveform_view loop_marks bank_sync browser_scroll param_slider tooltip
theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit 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 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 limiter meter_accumulate meter_ballistics master_meter bake_hold
file_bytes curve_law stroke_aa file_bytes curve_law stroke_aa
curve_tessellate curve_tessellate
+95
View File
@@ -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
+4 -4
View File
@@ -36,7 +36,6 @@ using instrument::ui::kDeckKnobSize;
using instrument::ui::kPad; using instrument::ui::kPad;
using instrument::ui::deckParamNorm; using instrument::ui::deckParamNorm;
using instrument::ui::kEnvTimeMaxSeconds; using instrument::ui::kEnvTimeMaxSeconds;
using instrument::ui::kKeyTrackMax;
using instrument::ui::resetDeckParam; using instrument::ui::resetDeckParam;
using instrument::ui::sampleDeckGroups; using instrument::ui::sampleDeckGroups;
using instrument::ui::setDeckParam; using instrument::ui::setDeckParam;
@@ -119,7 +118,7 @@ double ReaSamplerEditor::deckControlNorm(int id) const {
if (id == kBakeHoldKnobId) return bakeHoldNorm(); if (id == kBakeHoldKnobId) return bakeHoldNorm();
switch (static_cast<ParamControl>(id)) { switch (static_cast<ParamControl>(id)) {
case ParamControl::kKeyTrack: case ParamControl::kKeyTrack:
return clamp01(params_.keyTrack / kKeyTrackMax); return instrument::ui::keyTrackNormFrom(params_.keyTrack);
case ParamControl::kVoiceCount: case ParamControl::kVoiceCount:
return clamp01(static_cast<double>(voiceCount_ - kMinVoiceCount) / return clamp01(static_cast<double>(voiceCount_ - kMinVoiceCount) /
static_cast<double>(kMaxVoiceCount - kMinVoiceCount)); static_cast<double>(kMaxVoiceCount - kMinVoiceCount));
@@ -272,8 +271,9 @@ EnvClampBounds ReaSamplerEditor::envClampBounds() const {
void ReaSamplerEditor::applyParamControl(int id, double value, int segment) { void ReaSamplerEditor::applyParamControl(int id, double value, int segment) {
if (id == static_cast<int>(ParamControl::kKeyTrack)) { if (id == static_cast<int>(ParamControl::kKeyTrack)) {
// keyTrack sits beside the play bundle (0..200% over kKeyTrackMax); the knob maps 0..1. // keyTrack sits beside the play bundle, so it takes deck_values' own pair rather than
params_.keyTrack = clamp01(value) * kKeyTrackMax; // the PlaySeconds binding.
params_.keyTrack = instrument::ui::keyTrackFromNorm(value);
} else { } else {
applyControl(id, params_.play, value, segment); applyControl(id, params_.play, value, segment);
} }
+16 -1
View File
@@ -70,7 +70,7 @@ void ReaSamplerEditor::refreshFromBank() {
samples_ = banksJson ? listSamples(*banksJson) : std::vector<SampleChoice>{}; samples_ = banksJson ? listSamples(*banksJson) : std::vector<SampleChoice>{};
banks_ = banksJson ? listBanks(*banksJson) : std::vector<BankChoice>{}; banks_ = banksJson ? listBanks(*banksJson) : std::vector<BankChoice>{};
selectedId_ = processor_->selectedSampleId(); selectedId_ = processor_->selectedSampleId();
params_ = processor_->instrumentParams(); params_ = processor_->instrumentParams(seenParamsGeneration_);
channelMode_ = processor_->channelMode(); channelMode_ = processor_->channelMode();
voiceCount_ = processor_->voiceCount(); voiceCount_ = processor_->voiceCount();
voiceMode_ = processor_->voiceMode(); voiceMode_ = processor_->voiceMode();
@@ -146,6 +146,17 @@ void ReaSamplerEditor::onSyncTimer() {
processor_->flushGainNotify(); processor_->flushGainNotify();
processor_->drainAutomationToModel(); 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 // 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. // can never be enabled on one tick and refuse on the next.
const bool available = bakeAvailable(processor_->bridge()); const bool available = bakeAvailable(processor_->bridge());
@@ -201,6 +212,9 @@ void ReaSamplerEditor::commitAndReload() {
if (!processor_) return; if (!processor_) return;
processor_->setSelectedSampleId(selectedId_); processor_->setSelectedSampleId(selectedId_);
processor_->setInstrumentParams(params_); 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(); processor_->reloadInstrument();
// The reload may have auto-defaulted the channel mode (implicit only) — re-read so the // The reload may have auto-defaulted the channel mode (implicit only) — re-read so the
// toggle draws what the engine actually decoded with. // 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. // UI thread only. See the declaration for why this still writes the parameter set.
if (!processor_) return; if (!processor_) return;
processor_->setInstrumentParams(params_); processor_->setInstrumentParams(params_);
seenParamsGeneration_ = processor_->instrumentParamsGeneration();
processor_->publishLiveParams(); processor_->publishLiveParams();
} }
+48 -24
View File
@@ -13,6 +13,7 @@
#include "core/instrument/engine/master_gain.h" #include "core/instrument/engine/master_gain.h"
#include "core/instrument/param/param_format.h" #include "core/instrument/param/param_format.h"
#include "core/instrument/param/param_id.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/param/param_units.h"
#include "core/instrument/ui/deck_values.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, void ReaSamplerProcessor::writeDeckParamToModel(InstrumentParams& params, DeckParam deck,
double normalized) { double normalized) {
// The two homes a control's value can have, and the ONE place the host path knows the // WHICH home a control's value has is param::valueHomeFor's answer, not a list repeated here
// difference — the editor draws the same split at applyParamControl. param::valueHomeFor is // — the read peer below branches on the same predicate, the editor draws the same split at
// the predicate; a promotion whose control has neither home fails param_units' own test // applyParamControl, and the instance-scalar set's ARITY is pinned by param_live's own test,
// rather than no-oping silently here. // so a third one cannot appear without a failure. The value map itself is param_live's, which
if (deck == DeckParam::kKeyTrack) { // is what makes this write and the audio thread's patch of the same point agree.
params.keyTrack = instrument::ui::keyTrackFromNorm(normalized); 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; return;
} }
instrument::ui::setDeckParam(deck, params.play, normalized, /*segment=*/0); param::writeHostParam(deck, params.play, normalized);
} }
double ReaSamplerProcessor::modelParamNormalized(const InstrumentParams& params, double ReaSamplerProcessor::modelParamNormalized(const InstrumentParams& params,
DeckParam deck) const { DeckParam deck) const {
if (deck == DeckParam::kMasterGain) { if (param::valueHomeFor(deck) == param::ValueHome::InstanceScalar) {
return instrument::engine::masterGainNormFromLinear(masterGainLinear()); 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); return instrument::ui::deckParamNorm(deck, params.play);
} }
@@ -196,7 +203,11 @@ void ReaSamplerProcessor::notifyParamChanged(param::ParamId id, double normalize
} }
if (gestureLatching_ && openGestureCount_ < kMaxOpenGestures) { if (gestureLatching_ && openGestureCount_ < kMaxOpenGestures) {
// First move this drag has made on this parameter: open its bracket and hold it, so the // 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; openGestureIds_[openGestureCount_++] = id;
beginEdit(id); beginEdit(id);
performEdit(id, normalized); performEdit(id, normalized);
@@ -244,34 +255,40 @@ bool ReaSamplerProcessor::drainInputParameterChanges(IParameterChanges* changes)
if (queue->getPoint(points - 1, offset, value) != kResultTrue) continue; if (queue->getPoint(points - 1, offset, value) != kResultTrue) continue;
const param::ParamRow* row = param::exposedRowFor(queue->getParameterId()); const param::ParamRow* row = param::exposedRowFor(queue->getParameterId());
if (!row) continue; 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 // 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. // automation write is the same one relaxed store the knob makes — and it takes no hold,
if (row->deck == DeckParam::kMasterGain) { // 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( masterGain_.store(
static_cast<float>(instrument::engine::masterGainLinearFromNorm(value)), static_cast<float>(instrument::engine::masterGainLinearFromNorm(value)),
std::memory_order_relaxed); 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. // authoritative, so a value that never came back would be lost on save.
automationPublished_[slot].store(value, std::memory_order_relaxed); landed |= automation_.land(static_cast<std::size_t>(row->deck), value, ridesTheBlock);
automationPending_[slot].store(true, std::memory_order_release);
} }
if (landed) automationAny_.store(true, std::memory_order_release);
return landed; return landed;
} }
void ReaSamplerProcessor::drainAutomationToModel() { void ReaSamplerProcessor::drainAutomationToModel() {
if (!automationAny_.exchange(false, std::memory_order_acquire)) return; if (!automation_.takePending()) return;
InstrumentParams params = instrumentParams(); 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; bool moved = false;
for (const param::ParamRow& row : param::exposedParams()) { for (const param::ParamRow& row : param::exposedParams()) {
const auto slot = static_cast<std::size_t>(row.deck); const auto slot = static_cast<std::size_t>(row.deck);
if (!automationPending_[slot].exchange(false, std::memory_order_acquire)) continue; double value = 0.0;
const double value = automationPublished_[slot].load(std::memory_order_relaxed); 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 // Master gain's model IS the atomic the audio thread already wrote; there is nothing to
// fold, only the controller cache to refresh below. // fold, only the controller cache to refresh below.
if (row.deck != DeckParam::kMasterGain) { if (row.deck != DeckParam::kMasterGain) {
@@ -290,6 +307,13 @@ void ReaSamplerProcessor::drainAutomationToModel() {
} }
syncParamsFromModel(); syncParamsFromModel();
paramNotifySuppressed_ = wasSuppressed; 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() { void ReaSamplerProcessor::endParamGesture() {
+6 -1
View File
@@ -153,13 +153,18 @@ std::string ReaSamplerProcessor::reloadInstrument() {
// top of the first block after this, which is where the swap below becomes visible // 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. // too — so the new snapshot's first note reads it.
sample.live = &automationLive_; 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; // reloadMutex_ (held for this whole function) nests livePublishMutex_ here;
// publishLiveParams never holds reloadMutex_, so this is the only nesting. // publishLiveParams never holds reloadMutex_, so this is the only nesting.
std::lock_guard<std::mutex> lp(livePublishMutex_); std::lock_guard<std::mutex> lp(livePublishMutex_);
liveParams_.publish(instrument::engine::foldLive(sample.play, sample.keyTrack)); liveParams_.publish(instrument::engine::foldLive(sample.play, sample.keyTrack));
} }
builtSampleRate_.store(sample.sampleRate, std::memory_order_relaxed);
resolvedId = selId; // the concrete pick that resolved resolvedId = selId; // the concrete pick that resolved
} }
} }
+57
View File
@@ -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
+14
View File
@@ -168,6 +168,17 @@ InstrumentParams ReaSamplerProcessor::instrumentParams() {
return params_; 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) { void ReaSamplerProcessor::setInstrumentParams(const InstrumentParams& params) {
// The exposed values alone, not the whole set: this funnel fires per mouse move on every live // 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 // 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); before[static_cast<std::size_t>(row.deck)] = modelParamNormalized(params_, row.deck);
} }
params_ = params; 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 — // 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 // funnels through here, so mirroring the limiter flag at this one point is what keeps the
+5 -1
View File
@@ -420,7 +420,11 @@ private:
std::vector<BankChoice> banks_; // the named banks, for the filter tab strip std::vector<BankChoice> banks_; // the named banks, for the filter tab strip
std::vector<SampleChoice> visible_; // samples_ narrowed by the active bank filter std::vector<SampleChoice> visible_; // samples_ narrowed by the active bank filter
std::string selectedId_; // the loaded capture ("" = empty state) 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 ChannelMode channelMode_ = ChannelMode::Mono; // mono/stereo toggle snapshot
// Mirrors of the processor's persisted voice-system params, refreshed with the rest of the // Mirrors of the processor's persisted voice-system params, refreshed with the rest of the
+22 -15
View File
@@ -17,7 +17,7 @@
#include "pluginterfaces/vst/vstspeaker.h" #include "pluginterfaces/vst/vstspeaker.h"
#include "core/instrument/engine/master_gain.h" // the automation write of the gain's own atomic #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_editor.h" // createView hands the host our IPlugView editor
#include "shell/instrument/reasampler_embed.h" // embed shell + IReaperUIEmbedInterface (its iid DEF'd there) #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. // the merge is here rather than in the model's own publisher.
{ {
const bool dirty = drainInputParameterChanges(data.inputParameterChanges); const bool dirty = drainInputParameterChanges(data.inputParameterChanges);
const std::uint32_t modelGen = liveParams_.generation(); if (dirty || liveParams_.generation() != seenModelGeneration_) {
if (dirty || modelGen != 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 // Declared INSIDE the branch: LiveValues carries default member initializers, so a
// block where nothing moved must not pay to construct one. // block where nothing moved must not pay to construct one.
instrument::engine::LiveValues merged; instrument::engine::LiveValues merged;
// Re-read the model's fold and re-apply every held automation value over it: without // The generation ACTUALLY observed, not the one sampled above: a publish landing
// the re-apply, any knob move would revert an automated parameter until its lane's // between the two would otherwise leave this thread re-merging an identical block
// next point. // every following quiet one.
if (liveParams_.read(merged) != 0) { const std::uint32_t observed = liveParams_.read(merged);
seenModelGeneration_ = modelGen; if (observed != 0) {
const int builtRate = builtSampleRate_.load(std::memory_order_relaxed); seenModelGeneration_ = observed;
for (std::size_t i = 0; i < kDeckParamSlots; ++i) { instrument::param::mergeAutomation(
if (!automationHeld_[i]) continue; merged, automation_.slots(), kDeckParamSlots,
instrument::param::applyLiveParam( builtSampleRate_.load(std::memory_order_relaxed));
merged, static_cast<instrument::ui::DeckParam>(i), automationNorm_[i], // The second gate, and a different window from the drain's own: this one catches
builtRate); // 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);
} }
} }
} }
+36 -69
View File
@@ -17,13 +17,14 @@
#include "public.sdk/source/vst/vstsinglecomponenteffect.h" #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 "shell/instrument/reaper_bridge.h"
#include "core/instrument/map/sample_map.h" // InstrumentParams (the one parameter set) #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/map/component_state_io.h" // ComponentState codec
#include "core/instrument/engine/limiter.h" // the master bus's post-gain limiter #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/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/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 #include "core/instrument/param/param_id.h" // the frozen ParamId space + DeckParam binding
namespace reasampler::vst { namespace reasampler::vst {
@@ -36,48 +37,6 @@ using instrument::map::kPreviewVelocityDefault;
class ReaSamplerEmbed; // embedded TCP/MCP UI shell (owned below; see queryInterface) 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 { class ReaSamplerProcessor : public Steinberg::Vst::SingleComponentEffect {
public: public:
ReaSamplerProcessor() = default; ReaSamplerProcessor() = default;
@@ -140,9 +99,11 @@ public:
void syncParamsFromModel(); void syncParamsFromModel();
// Folds what the audio thread took from the host's parameter queue back into the model and // 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). // the controller cache, suppressing the host notification (those values came FROM it), then
// UI/main thread; a no-op when nothing was automated. Called wherever the model is about to // RELEASES each folded point's hold — which is what bounds a lane's authority to the window
// be READ as authoritative — getState, the bake, the editor's tick. // 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(); void drainAutomationToModel();
// A drag's host-edit bracket, so a host in touch or latch mode records ONE continuous edit // 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(); InstrumentParams instrumentParams();
void setInstrumentParams(const InstrumentParams& params); 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 // 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 // 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 // voices already latched. THE tier-3 commit (the three tiers are listed in this
@@ -320,10 +287,10 @@ private:
void buildParameterList(); void buildParameterList();
// THE automation read, on the audio thread, at the BLOCK BOUNDARY: the last point of each // 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 // queue wins. RT-safe — relaxed/release atomic stores only. Sample-accurate application would
// per-sample "did anything change" question on the per-voice-per-sample path, which the // 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 // phase-wide guardrail forbids. True when at least one point landed ON A CONTROL THE BLOCK
// merge below it conditional. // CARRIES, which is what makes the merge below it conditional.
bool drainInputParameterChanges(Steinberg::Vst::IParameterChanges* changes); bool drainInputParameterChanges(Steinberg::Vst::IParameterChanges* changes);
// The normalized value a control reads at, from the model — a projection of it, never a // 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. // leave the drain's still-sounding voices deaf to the knob under them.
instrument::engine::LiveParams liveParams_; instrument::engine::LiveParams liveParams_;
// The block the ENGINE reads, and the ONE thing SampleData::live points at. Written only by // 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 // the audio thread, which merges liveParams_ with the host's automation points once per block
// block and republishes ONLY when either side moved — so a block carrying neither costs one // and republishes ONLY when the RESULT moved — so neither an unchanged model nor a lane
// relaxed load and the engine's own read shape is unchanged. Two blocks because the seqlock's // resending the value it already sent reaches the per-voice fan-out. A block carrying no
// single-writer contract is load-bearing and the two writers differ in thread; this // automation and no model change costs one relaxed load plus, when the host passed a non-null
// directory's CLAUDE.md owns the argument. // 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_; instrument::engine::LiveParams automationLive_;
// Audio thread only. The last liveParams_ generation merged, and the sticky automation values // Audio thread only. The last liveParams_ generation merged, the last block published (the
// re-applied over every merge — without them a model republish (any knob move) would revert // republish gate compares against it), and the automation slots themselves.
// an automated parameter until its lane's next point.
std::uint32_t seenModelGeneration_ = 0; std::uint32_t seenModelGeneration_ = 0;
static constexpr std::size_t kDeckParamSlots = instrument::engine::LiveValues lastMergedLive_{};
static_cast<std::size_t>(instrument::ui::DeckParam::kCount); bool haveMergedLive_ = false;
double automationNorm_[kDeckParamSlots] = {}; // The host lane's state and its release protocol; automation_channel.h owns the mechanism and
bool automationHeld_[kDeckParamSlots] = {}; // this directory's CLAUDE.md the authority model it enforces.
// The audio thread's publication of those values to the UI thread's fold. automationAny_ AutomationChannel automation_;
// makes the idle drain a single load.
std::atomic<double> automationPublished_[kDeckParamSlots] = {};
std::atomic<bool> automationPending_[kDeckParamSlots] = {};
std::atomic<bool> automationAny_{false};
// Serializes liveParams_.publish's two writer sites (reloadInstrument, publishLiveParams) // Serializes liveParams_.publish's two writer sites (reloadInstrument, publishLiveParams)
// only — separate from reloadMutex_ so a knob drag's publish never blocks behind a // 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 // reload's WAV decode. The audio thread never takes this; process() only reads via
@@ -509,10 +473,13 @@ private:
std::mutex selectionMutex_; std::mutex selectionMutex_;
std::string selectedSampleId_; std::string selectedSampleId_;
// The one parameter set. Off-thread only; reloadInstrument bakes it into the SampleData // The one parameter set — THE model, and the authority every other holder of these values
// under the reload lock, never read directly on the audio thread. // 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_; std::mutex paramsMutex_;
InstrumentParams params_; InstrumentParams params_;
std::uint32_t paramsGeneration_ = 0;
// Instance-owned sample refs: path + intrinsics per referenced sample. Refreshed // Instance-owned sample refs: path + intrinsics per referenced sample. Refreshed
// opportunistically from the bank blob when readable; never a bank dependency for // opportunistically from the bank blob when readable; never a bank dependency for
+108 -2
View File
@@ -938,6 +938,106 @@ static void testAPublishedPitchOffsetRefitsThePitchEnvelopeSpan() {
if (!(life > 11000 && life < 13000)) std::printf(" refit span: life %zu\n", life); 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 = &block;
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 = &block;
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 ------------------------------------------------------- // --- What stays latched at note-on -------------------------------------------------------
static void testPitchRatioAndVelocityGainStayLatched() { static void testPitchRatioAndVelocityGainStayLatched() {
@@ -966,8 +1066,12 @@ static void testPitchRatioAndVelocityGainStayLatched() {
std::vector<AudioSample> out; std::vector<AudioSample> out;
LiveValues hostile = foldLive(s.play, s.keyTrack); LiveValues hostile = foldLive(s.play, s.keyTrack);
// Everything the block CAN carry, moved as far as it goes. None of it names velocity, the // Everything the block CAN carry, moved as far as it goes. keyTrack and lengthFraction DO
// note, the pitch ratio, or the PCM — that is the property under test. // 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.filterKeyTrack = 2.0;
hostile.filterSettings.cutoffNorm = 0.0f; hostile.filterSettings.cutoffNorm = 0.0f;
hostile.filterModAmount = 1.0; hostile.filterModAmount = 1.0;
@@ -1078,6 +1182,8 @@ int main() {
testEveryLiveFilterControlMovesTheSoundingNote(); testEveryLiveFilterControlMovesTheSoundingNote();
testOneBlockServesTwoIndependentObservers(); testOneBlockServesTwoIndependentObservers();
testARateChangeSpareTheSoundingNoteAndReachesTheNextOne(); testARateChangeSpareTheSoundingNoteAndReachesTheNextOne();
testAKeyTrackChangeSparesTheSoundingNoteAndReachesTheNextOne();
testATriggerLengthChangeSparesTheSoundingNoteAndReachesTheNextOne();
testAPitchOffsetChangeMovesTheSoundingNoteInBothEngines(); testAPitchOffsetChangeMovesTheSoundingNoteInBothEngines();
testAPublishedPitchOffsetLeavesTheStagedAttackWallClock(); testAPublishedPitchOffsetLeavesTheStagedAttackWallClock();
testAPublishedPitchOffsetRefitsThePitchEnvelopeSpan(); testAPublishedPitchOffsetRefitsThePitchEnvelopeSpan();
+51 -12
View File
@@ -1,6 +1,7 @@
// Standalone tests for the audio thread's parameter patch. The load-bearing one is the // Standalone tests for a host parameter write, both sides of the model/audio split. The
// EQUIVALENCE assertion: patching a control into the live block must produce, bit for bit, the // load-bearing one is the EQUIVALENCE assertion: patching a control into the live block must
// block the model path would have folded — which is what makes a second routing table safe. // 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" #include "../src/core/instrument/param/param_live.h"
@@ -8,9 +9,10 @@
#include "../src/core/instrument/param/param_units.h" #include "../src/core/instrument/param/param_units.h"
#include "../src/core/instrument/map/sample_map.h" #include "../src/core/instrument/map/sample_map.h"
#include "../src/core/instrument/ui/deck_values.h" #include "../src/core/instrument/ui/deck_values.h"
#include "../src/core/util/curve_law.h"
#include <cstdio> #include <cstdio>
#include <cstring>
using namespace reasampler; using namespace reasampler;
using namespace reasampler::instrument::param; using namespace reasampler::instrument::param;
@@ -71,9 +73,13 @@ InstrumentParams dialledParams() {
} // namespace } // namespace
// THE assertion this module exists for. For every exposed control and several normalized // 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 // positions: writing it through the MODEL side of a host write and folding must equal patching it
// block. Bytes, not fields — a member the patch forgot to route is caught as surely as one it // into the folded block. Whole-block, not per-field — a member the patch forgot to route is
// routed to the wrong place. // 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() { static void testPatchingAControlEqualsFoldingTheModelAfterTheSameWrite() {
const double kPositions[] = {0.0, 0.137, 0.5, 0.813, 1.0}; const double kPositions[] = {0.0, 0.137, 0.5, 0.813, 1.0};
for (const ParamRow& row : exposedParams()) { for (const ParamRow& row : exposedParams()) {
@@ -83,7 +89,7 @@ static void testPatchingAControlEqualsFoldingTheModelAfterTheSameWrite() {
LiveValues block = modelBlock(dialledParams()); LiveValues block = modelBlock(dialledParams());
const LiveValues before = block; const LiveValues before = block;
CHECK_ID(!applyLiveParam(block, row.deck, 0.25, kRate), row.id); 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; continue;
} }
for (double norm : kPositions) { for (double norm : kPositions) {
@@ -91,14 +97,13 @@ static void testPatchingAControlEqualsFoldingTheModelAfterTheSameWrite() {
if (row.deck == DeckParam::kKeyTrack) { if (row.deck == DeckParam::kKeyTrack) {
written.keyTrack = reasampler::instrument::ui::keyTrackFromNorm(norm); written.keyTrack = reasampler::instrument::ui::keyTrackFromNorm(norm);
} else { } else {
reasampler::instrument::ui::setDeckParam(row.deck, written.play, norm, CHECK_ID(writeHostParam(row.deck, written.play, norm), row.id);
/*segment=*/0);
} }
const LiveValues expected = modelBlock(written); const LiveValues expected = modelBlock(written);
LiveValues patched = modelBlock(dialledParams()); LiveValues patched = modelBlock(dialledParams());
CHECK_ID(applyLiveParam(patched, row.deck, norm, kRate), row.id); 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; const LiveValues before = block;
CHECK(!applyLiveParam(block, DeckParam::kPlayMode, 1.0, kRate)); CHECK(!applyLiveParam(block, DeckParam::kPlayMode, 1.0, kRate));
CHECK(!applyLiveParam(block, DeckParam::kVoiceCount, 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 // 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); 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() { int main() {
testPatchingAControlEqualsFoldingTheModelAfterTheSameWrite(); testPatchingAControlEqualsFoldingTheModelAfterTheSameWrite();
testTriggerLengthIsInertUnderADrawnEnvelope(); testTriggerLengthIsInertUnderADrawnEnvelope();
testAnUnexposedControlIsRefused(); testAnUnexposedControlIsRefused();
testEveryExposedControlHasAValueHome(); testEveryExposedControlHasAValueHome();
testTheHostSkipsTheCurveDetentAndNothingElse();
if (g_fail == 0) std::printf("param_live: all tests passed\n"); if (g_fail == 0) std::printf("param_live: all tests passed\n");
return g_fail == 0 ? 0 : 1; return g_fail == 0 ? 0 : 1;
} }
+185
View File
@@ -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;
}
+23
View File
@@ -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() { static void testTheFiltersFourTakeTheirStoredNormVerbatim() {
// Their stored value IS the normalized one, so no taper may participate in their default: // 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)). // this fails the moment someone routes them through toNormalized(toPlain(x)).
@@ -218,6 +240,7 @@ int main() {
testEveryUnitStringAndRangeMatchesTheSpecifiedTable(); testEveryUnitStringAndRangeMatchesTheSpecifiedTable();
testEveryDefaultHasAnExactNormalizedPreimage(); testEveryDefaultHasAnExactNormalizedPreimage();
testTheHostAndTheEditorAgreeOnEveryDefaultPosition(); testTheHostAndTheEditorAgreeOnEveryDefaultPosition();
testADoubleClickResetLandsOnTheHostsDefaultNormalized();
testTheFiltersFourTakeTheirStoredNormVerbatim(); testTheFiltersFourTakeTheirStoredNormVerbatim();
testToPlainIsMonotoneAcrossTheWholeTravel(); testToPlainIsMonotoneAcrossTheWholeTravel();
testTheEndpointsAreTheDeclaredPlainRange(); testTheEndpointsAreTheDeclaredPlainRange();