Service both VST3 parameter channels, and promote pitch key-track and Trigger length so all 44 ids issue
The SDK's own single-component sample drains inputParameterChanges in process() and implements setParamNormalized; automation was reading the GUI channel alone. The audio thread now patches a block it solely owns.
This commit is contained in:
+27
-16
@@ -1277,7 +1277,12 @@ value semantics, any deck geometry, or the bake's reset *membership* (W3-T2's).
|
|||||||
sound intact; and a project with automation drawn, saved and reopened, replays against the
|
sound intact; and a project with automation drawn, saved and reopened, replays against the
|
||||||
same plain values.
|
same plain values.
|
||||||
- **`process()` takes no new indirection and no new per-sample work** — the parameter read is
|
- **`process()` takes no new indirection and no new per-sample work** — the parameter read is
|
||||||
a block-boundary act, on the existing live-publish path.
|
a block-boundary act. **Correction to the original wording ("on the existing live-publish
|
||||||
|
path"):** it cannot be, and the SDK is what decides that. `IParameterChanges` is delivered ON
|
||||||
|
the audio thread, and the model's publish path allocates (`resolvePlay` copies velocity curves
|
||||||
|
and spline contours), so the drain lands in `process()` and patches the live block in place.
|
||||||
|
The block-boundary rule is unchanged and the per-sample path is untouched; what moved is which
|
||||||
|
thread performs the fold.
|
||||||
- **The bake's reset notifies the host**, verified by the host's displayed value following it
|
- **The bake's reset notifies the host**, verified by the host's displayed value following it
|
||||||
rather than snapping back on next touch.
|
rather than snapping back on next touch.
|
||||||
- **The double-processing limitation is documented, not discovered** — a bake whose
|
- **The double-processing limitation is documented, not discovered** — a bake whose
|
||||||
@@ -1297,11 +1302,17 @@ value semantics, any deck geometry, or the bake's reset *membership* (W3-T2's).
|
|||||||
- **No [Daniel] questions. Γ-F7 is RULED — signal flow** (2026-08-01, *"signal flow order."*),
|
- **No [Daniel] questions. Γ-F7 is RULED — signal flow** (2026-08-01, *"signal flow order."*),
|
||||||
and Ruling 3 (real units) arrived specified rather than forked. **There is no unanswered
|
and Ruling 3 (real units) arrived specified rather than forked. **There is no unanswered
|
||||||
[Daniel]-class question in this track or anywhere in this plan.**
|
[Daniel]-class question in this track or anywhere in this plan.**
|
||||||
- **[verify, FIRST]** that REAPER calls `setState` (not `setComponentState`) on a
|
- **CLOSED from the SDK, not the DAW.** Two things were bundled here and they separate.
|
||||||
single-component plug-in, and the ordering of `setState` against the first
|
(a) The DELIVERY CHANNEL: `ivsteditcontroller.h` documents `setParamNormalized` as the
|
||||||
`IParameterChanges` block after a project load. §6.1 is built on the SDK's own
|
GUI-update channel ("should update the according GUI element(s) only"), and the SDK's own
|
||||||
name-collapse; **verify it in the DAW before wiring, and do not build on the paragraph
|
`SingleComponentEffect` sample (`public.sdk/samples/vst/again/source/againsimple.cpp`) drains
|
||||||
alone.**
|
`ProcessData::inputParameterChanges` in `process()` while also implementing
|
||||||
|
`setParamNormalized`. **Both are serviced.** This was never a DAW question — the headers
|
||||||
|
answer it, and building on the paragraph alone is exactly what the first pass did.
|
||||||
|
(b) The ORDERING of `setState` against the first parameter block: no longer a question. An
|
||||||
|
automation point held by the audio thread is re-applied over every merge, so a written lane
|
||||||
|
outranks the restore whichever way round the two arrive — VST3's own rule. What survives as
|
||||||
|
DAW work is recorded in `docs/TODO.md`, and none of it can change the frozen contract.
|
||||||
- **[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
|
||||||
@@ -1309,16 +1320,16 @@ value semantics, any deck geometry, or the bake's reset *membership* (W3-T2's).
|
|||||||
REAPER shows no unit at all, the fallback is to append the unit **inside the one formatter**:
|
REAPER shows no unit at all, the fallback is to append the unit **inside the one formatter**:
|
||||||
a one-line change in one place, touching neither the frozen id table nor the editor, because
|
a one-line change in one place, touching neither the frozen id table nor the editor, because
|
||||||
display strings are explicitly not frozen (§6.7.1). Do not discover this after shipping.
|
display strings are explicitly not frozen (§6.7.1). Do not discover this after shipping.
|
||||||
- **[propose at review]** promoting **key-track** and **Trigger length** from `Reload` to
|
- **RULED (Daniel, 2026-08-02): promote both.** **Key-track** and **Trigger length** move from
|
||||||
`NoteOnLatched` (§7.4). Both are excluded from the live set *for the note-on-latch reason*
|
`Reload` to `NoteOnLatched` (§7.4) and are exposed; ids **1000** and **1450** issue and the
|
||||||
in the predicate's own words, so the promotion aligns routing with documented semantics —
|
count is **44 of 44**. **The promotion is NOT the predicate-only change this bullet originally
|
||||||
and it is what makes them automatable at all. **If either promotion is refused, that
|
advertised** — the predicate flip is the smallest part of it. Key-track lives on
|
||||||
control simply drops out of the parameter list.** The list follows the predicate; the
|
`InstrumentParams`, not `PlaySeconds`, so the host's write path (`setDeckParam`/`deckParamNorm`)
|
||||||
predicate is never bent to fill the list. **Consequence for the frozen table:** a refusal
|
structurally could not see it and id 1000 would have no-oped in both directions with no
|
||||||
drops ids 1000 and 1260 (key-track) or 1450 (Trigger length) and the count falls below 44.
|
compile-time guard; both controls also had to reach the engine, which meant widening
|
||||||
Those slots are then simply **never issued** — not retired, since nothing shipped under
|
`LiveValues` and `foldLive`'s input and handing `Voice::start` the two latched values as
|
||||||
them — and remain available to the same control if it is promoted later. No other id moves;
|
arguments beside the rate. The guard that closes the class is `param::valueHomeFor`, asserted
|
||||||
that is what the block-and-step scheme buys.
|
over the exposed set.
|
||||||
- **[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
|
||||||
|
|||||||
@@ -819,3 +819,20 @@ doc-keeper edit.
|
|||||||
|
|
||||||
**Done looks like.** The enumeration distinguishes "in the project's state" from "on disk
|
**Done looks like.** The enumeration distinguishes "in the project's state" from "on disk
|
||||||
in the `.rpp`", and does not gain a second home for the distinction.
|
in the `.rpp`", and does not gain a second home for the distinction.
|
||||||
|
|
||||||
|
## The VST3 parameter surface's DAW-verifiable claims
|
||||||
|
|
||||||
|
**Context (what shipped — Γ-W4-T1).** The instrument reports 44 automatable parameters under the frozen id table, services both delivery channels (the controller's `setParamNormalized` and the audio thread's `IParameterChanges` drain), and folds automated values back into the blob on the UI thread.
|
||||||
|
|
||||||
|
**What is settled without a DAW.** The channel question itself is answered by the vendored SDK, not by observation: `ivsteditcontroller.h` documents `setParamNormalized` as the GUI-update channel ("should update the according GUI element(s) only"), and the SDK's own `SingleComponentEffect` sample (`public.sdk/samples/vst/again/source/againsimple.cpp`) drains `ProcessData::inputParameterChanges` in `process()` while also implementing `setParamNormalized`. Servicing both is what the SDK's own precedent does; it needs no verification, only exercise.
|
||||||
|
|
||||||
|
**What genuinely needs a running REAPER, and why none of it can change the design.** Each item below is a host BEHAVIOUR, not a contract — the plug-in is correct under either answer, so discovering the answer costs a display fix at worst:
|
||||||
|
|
||||||
|
1. **Whether REAPER renders `ParameterInfo::units` beside the string `getParamStringByValue` returns, or shows the string alone.** We ship the SDK's own convention (digits in the string, unit carried separately). If REAPER shows no unit at all, the fallback is to append the unit inside the one formatter — one line in one place, touching neither the frozen id table nor the editor, because display strings are explicitly not frozen.
|
||||||
|
2. **Whether REAPER's own per-parameter MIDI learn covers what a shipped `IMidiMapping` CC table would have.** The decision to ship no default map rests on it; if learn does not reach these parameters, a CC table is additive and frozen by nothing.
|
||||||
|
3. **That the three migration round trips hold**: a pre-parameter project opens with every parameter reading the blob's value and sounds identical; a project saved by this build restores fully in an older binary; a project with automation drawn, saved and reopened, replays against the same plain values.
|
||||||
|
4. **That an offline render replays automation** — the sharpest case for the audio-side drain, because the host drives `process()` and may never touch the controller.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
**Done looks like.** Each of the four exercised once in REAPER, with the unit-rendering answer recorded and, if it went the other way, the one-line formatter change made.
|
||||||
|
|||||||
@@ -336,10 +336,18 @@ invariant Θ-W1-T1 was run to establish.
|
|||||||
> automation and is not a defect to design away — but it has one sharp consequence for the
|
> automation and is not a defect to design away — but it has one sharp consequence for the
|
||||||
> resample bake, and that is §9.
|
> resample bake, and that is §9.
|
||||||
|
|
||||||
**[verify] at the track, before wiring:** that REAPER calls `setState` (not
|
**SETTLED at the track, from the vendored SDK.** The delivery question the `[verify]` here
|
||||||
`setComponentState`) on a single-component plug-in, and the ordering of `setState` against
|
bundled is answered by the headers rather than by the DAW: `setParamNormalized` is documented as
|
||||||
the first `IParameterChanges` block after a project load. Verify against the vendored SDK
|
the GUI-update channel (*"should update the according GUI element(s) only"*,
|
||||||
and in the DAW — do not build on the paragraph above without it.
|
`ivsteditcontroller.h`), and `ProcessData::inputParameterChanges` is the audio-side one — the
|
||||||
|
SDK's own `SingleComponentEffect` sample services BOTH
|
||||||
|
(`public.sdk/samples/vst/again/source/againsimple.cpp`), and so do we. The `setState` ordering
|
||||||
|
half dissolves with it: an automation point held by the audio thread is re-applied over every
|
||||||
|
merge, so a written lane outranks the restore whichever way round the two arrive, which is
|
||||||
|
VST3's own authority rule rather than a race. **The audio thread cannot run the model path**
|
||||||
|
(`resolvePlay` copies velocity curves and spline contours, so it allocates), so the drain patches
|
||||||
|
the live block in place through one pure RT-safe function whose routing is pinned by an
|
||||||
|
exhaustive equivalence test against the model path.
|
||||||
|
|
||||||
### 6.2 The ID space: hand-assigned constants in one frozen table
|
### 6.2 The ID space: hand-assigned constants in one frozen table
|
||||||
|
|
||||||
@@ -897,11 +905,16 @@ state fits them exactly:
|
|||||||
- **Trigger length** — *"resolves `playEnd_`, a fact about the note, not a setting of it."*
|
- **Trigger length** — *"resolves `playEnd_`, a fact about the note, not a setting of it."*
|
||||||
Same shape.
|
Same shape.
|
||||||
|
|
||||||
**[propose at review, Γ-W4-T1]** promote both. The promotion aligns the routing with the
|
**RULED (Daniel, 2026-08-02): promote both.** Ids 1000 and 1450 issue; the count is 44 of 44.
|
||||||
predicate's own stated semantics — and it is what makes them automatable, since today they
|
The promotion aligns the routing with the predicate's own stated semantics — and it is what
|
||||||
would re-decode a WAV per automation point. **If either promotion is refused, that control
|
makes them automatable, since otherwise they would re-decode a WAV per automation point.
|
||||||
simply drops out of the parameter list.** The list follows the predicate; the predicate is
|
|
||||||
never bent to fill the list.
|
**It was not the predicate-only change this section implied.** Key-track lives on
|
||||||
|
`InstrumentParams`, not `PlaySeconds`, so the host's write path could not reach it and id 1000
|
||||||
|
would have no-oped in both directions with nothing failing to compile; both controls also had to
|
||||||
|
reach the engine, which widened `LiveValues` and `foldLive`'s input and gave `Voice::start` the
|
||||||
|
two latched values as arguments beside the rate. `param::valueHomeFor`, asserted over the
|
||||||
|
exposed set, is what makes the next promotion of this shape a test failure instead of a silence.
|
||||||
|
|
||||||
**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,8 +1,8 @@
|
|||||||
# 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)
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
The ReaSampler 9000 instrument's pure, REAPER-free, VST3-free, unit-tested core, in five
|
The ReaSampler 9000 instrument's pure, REAPER-free, VST3-free, unit-tested core, in six
|
||||||
subdirectories:
|
subdirectories:
|
||||||
|
|
||||||
- **`engine/`** — the polyphonic voice engine, the one set of play params, pitch shifting,
|
- **`engine/`** — the polyphonic voice engine, the one set of play params, pitch shifting,
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
# Control mapping, SVF coefficients, morph weights, and the filter type each get their own
|
# Control mapping, SVF coefficients, morph weights, and the filter type each get their own
|
||||||
# TU; VoiceFilter::process stays header-inline so the kernel still inlines at the call site.
|
# TU; VoiceFilter::process stays header-inline so the kernel still inlines at the call site.
|
||||||
|
# The frozen control-position laws are their own target: they are the filter's PARAMETER
|
||||||
|
# surface, and the VST3 parameter layer reports Hz/Q/drive through them. Kept separable so
|
||||||
|
# that consumer does not take a link edge onto the per-voice kernel — the extension's link
|
||||||
|
# graph must never be able to reach the voice DSP (root CLAUDE.md, the bake invariant).
|
||||||
|
reasampler_pure_library(filter_params SOURCES filter_params.cpp)
|
||||||
|
|
||||||
reasampler_pure_library(filter SOURCES
|
reasampler_pure_library(filter SOURCES
|
||||||
filter_params.cpp
|
|
||||||
filter_coeffs.cpp
|
filter_coeffs.cpp
|
||||||
filter_morph.cpp
|
filter_morph.cpp
|
||||||
voice_filter.cpp)
|
voice_filter.cpp
|
||||||
|
LINK PUBLIC filter_params)
|
||||||
|
|
||||||
# Four test targets along the module's own seams so each asserts one domain. filter_tests
|
# Four test targets along the module's own seams so each asserts one domain. filter_tests
|
||||||
# alone owns the analytic reference and the steady-state gain measurement — a forked copy of
|
# alone owns the analytic reference and the steady-state gain measurement — a forked copy of
|
||||||
|
|||||||
@@ -5,8 +5,13 @@
|
|||||||
|
|
||||||
namespace reasampler::instrument::engine {
|
namespace reasampler::instrument::engine {
|
||||||
|
|
||||||
LiveValues foldLive(const PlayParams& params) {
|
LiveValues foldLive(const PlayParams& params, double keyTrack) {
|
||||||
LiveValues v;
|
LiveValues v;
|
||||||
|
v.keyTrack = keyTrack;
|
||||||
|
// Folded here, not at the voice: Voice::start reads the block's value directly, so the
|
||||||
|
// spline rule has to be applied on the way in or the two would answer differently.
|
||||||
|
v.splineActive = splineActive(params);
|
||||||
|
v.lengthFraction = effectiveLengthFraction(params);
|
||||||
v.filterSettings = params.filter.settings;
|
v.filterSettings = params.filter.settings;
|
||||||
v.filterModAmount = params.filter.modAmount;
|
v.filterModAmount = params.filter.modAmount;
|
||||||
v.filterVelAmount = params.filter.velAmount;
|
v.filterVelAmount = params.filter.velAmount;
|
||||||
|
|||||||
@@ -52,6 +52,19 @@ struct LiveValues {
|
|||||||
// ordinarily live.
|
// ordinarily live.
|
||||||
double playRate = 1.0;
|
double playRate = 1.0;
|
||||||
double pitchOffsetSemitones = 0.0;
|
double pitchOffsetSemitones = 0.0;
|
||||||
|
// Two more members of playRate's note-on-latched class, here for the same reason it is:
|
||||||
|
// both resolve a fact the voice fixes at note-on (the pitch ratio, and playEnd_), so live
|
||||||
|
// delivery would retune or re-span a note already struck. Voice::start receives them as
|
||||||
|
// arguments; applyLive never touches either.
|
||||||
|
double keyTrack = kKeyTrackDefault;
|
||||||
|
// ALREADY spline-folded (effectiveLengthFraction) — a drawn contour is a pure time function
|
||||||
|
// over the whole sample, so the stored knob is inert while one is active and the block must
|
||||||
|
// carry what the voice will actually play, not the stored value.
|
||||||
|
double lengthFraction = 1.0;
|
||||||
|
// The drawn-EG state the fold above reads. A mode flip travels by reload like the contours
|
||||||
|
// themselves, so this is not a control; it rides here only so a block-boundary write of
|
||||||
|
// Trigger length (a host automation point) can apply the SAME fold rather than un-doing it.
|
||||||
|
bool splineActive = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
// The seqlock copies the block as raw bytes, which is only defensible for a plain value type.
|
// The seqlock copies the block as raw bytes, which is only defensible for a plain value type.
|
||||||
@@ -59,8 +72,10 @@ 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");
|
||||||
|
|
||||||
// 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.
|
// here so there is a single site to keep in step with PlayParams. `keyTrack` is passed in
|
||||||
LiveValues foldLive(const PlayParams& params);
|
// because it belongs to the capture/instrument scalar beside the play bundle, not to
|
||||||
|
// PlayParams — SampleData::keyTrack at the reload, InstrumentParams::keyTrack at a live commit.
|
||||||
|
LiveValues foldLive(const PlayParams& params, double keyTrack);
|
||||||
|
|
||||||
// Single-writer / single-reader seqlock. The writer publishes a whole block between an odd
|
// Single-writer / single-reader seqlock. The writer publishes a whole block between an odd
|
||||||
// and an even generation; the reader copies the block and re-checks the generation, retrying
|
// and an even generation; the reader copies the block and re-checks the generation, retrying
|
||||||
@@ -96,6 +111,10 @@ public:
|
|||||||
seq_.store(next, std::memory_order_release); // even: complete and coherent
|
seq_.store(next, std::memory_order_release); // even: complete and coherent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The last generation published, without copying the block — one relaxed load, so a reader
|
||||||
|
// that only needs "has anything moved" pays nothing for asking on a block where nothing has.
|
||||||
|
std::uint32_t generation() const { return seq_.load(std::memory_order_relaxed); }
|
||||||
|
|
||||||
// Copies the block into `out` and returns the generation actually observed, or 0 when
|
// Copies the block into `out` and returns the generation actually observed, or 0 when
|
||||||
// nothing has been published yet or the retry budget ran out (in which case `out` may hold
|
// nothing has been published yet or the retry budget ran out (in which case `out` may hold
|
||||||
// a torn copy and MUST be discarded — compare the return against 0 before using it).
|
// a torn copy and MUST be discarded — compare the return against 0 before using it).
|
||||||
|
|||||||
@@ -17,6 +17,13 @@ double masterGainMaxLinear() { return std::pow(10.0, kMasterGainMaxDb / 20.0); }
|
|||||||
double masterGainDbFromNorm(double norm) {
|
double masterGainDbFromNorm(double norm) {
|
||||||
norm = clamp01(norm);
|
norm = clamp01(norm);
|
||||||
if (norm <= 0.0) return -std::numeric_limits<double>::infinity();
|
if (norm <= 0.0) return -std::numeric_limits<double>::infinity();
|
||||||
|
// UNITY IS EXACT, and the argument is arithmetic rather than structural — a host's
|
||||||
|
// reset-to-default arrives here as toPlain(defaultNormalized) and must land on 0.0 dB, not a
|
||||||
|
// hair off it. fl(60/84) differs from 60/84 by δ ≈ 1.6e-17; 84·δ ≈ 1.33e-15 sits under the
|
||||||
|
// half-ulp of 60 (3.55e-15), so -60 + fl(60/84)·84 rounds to exactly 60 and the sum to 0.
|
||||||
|
// PRECONDITION: no FP contraction. Fused into a single FMA the residue survives as 1.33e-15.
|
||||||
|
// Safe on the shipped MSVC/x64 default (no FMA without /arch:AVX2); a build that enables
|
||||||
|
// contraction here breaks the exactness test in test_param_units, which is where it surfaces.
|
||||||
return kMasterGainMinDb + norm * (kMasterGainMaxDb - kMasterGainMinDb);
|
return kMasterGainMinDb + norm * (kMasterGainMaxDb - kMasterGainMinDb);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -148,6 +148,11 @@ struct FilterParams {
|
|||||||
// with the pitch envelope's own depth throw so the two pitch modulators speak one range.
|
// with the pitch envelope's own depth throw so the two pitch modulators speak one range.
|
||||||
inline constexpr double kVelocityPitchRangeSemitones = 24.0;
|
inline constexpr double kVelocityPitchRangeSemitones = 24.0;
|
||||||
|
|
||||||
|
// Standard 12-tone-ET tracking, and the ONE home for that number: the capture's own scalar, the
|
||||||
|
// instrument's stored scalar and the live block all default from here, so a blob predating the
|
||||||
|
// field and a block published before the first note can never disagree about it.
|
||||||
|
inline constexpr double kKeyTrackDefault = 1.0;
|
||||||
|
|
||||||
// Bundle a voice reads at start(). Defaults reproduce the bare engine (Gate, hold-0 AHDSR,
|
// Bundle a voice reads at start(). Defaults reproduce the bare engine (Gate, hold-0 AHDSR,
|
||||||
// Varispeed, pitch envelope off, filter off, no velocity->pitch) — core regression tests rely
|
// Varispeed, pitch envelope off, filter off, no velocity->pitch) — core regression tests rely
|
||||||
// on this; the Preserve product default is layered on at (de)serialization, see
|
// on this; the Preserve product default is layered on at (de)serialization, see
|
||||||
@@ -267,7 +272,7 @@ struct SampleData {
|
|||||||
// How far keyboard pitch tracks the root: 1.0 = standard 12-tone-ET (default); 0.0 = no
|
// How far keyboard pitch tracks the root: 1.0 = standard 12-tone-ET (default); 0.0 = no
|
||||||
// tracking (every key plays root pitch); 2.0 = double-rate. Scales the (note-root) semitone
|
// tracking (every key plays root pitch); 2.0 = double-rate. Scales the (note-root) semitone
|
||||||
// offset in keyTrackedRatio; rides both repitch engines via the voice's baseRatio_.
|
// offset in keyTrackedRatio; rides both repitch engines via the voice's baseRatio_.
|
||||||
double keyTrack = 1.0;
|
double keyTrack = kKeyTrackDefault;
|
||||||
|
|
||||||
// Maps note-on velocity (0..127) to the voice's amp gain, eval'd once in Voice::start
|
// Maps note-on velocity (0..127) to the voice's amp gain, eval'd once in Voice::start
|
||||||
// (never per frame). Default flat y=1 — every velocity plays at unity.
|
// (never per frame). Default flat y=1 — every velocity plays at unity.
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ void Voice::presizePreserveShifters(std::int64_t windowFrames) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void Voice::start(int note, int velocity, const SampleData& sample, bool declickTakeover,
|
void Voice::start(int note, int velocity, const SampleData& sample, bool declickTakeover,
|
||||||
double stretchRate) {
|
double stretchRate, double keyTrack, double lengthFraction) {
|
||||||
// Before any state reset, record the pre-cut reference (last rendered output) and mark
|
// Before any state reset, record the pre-cut reference (last rendered output) and mark
|
||||||
// the compensation pending iff this start is a takeover/steal of a sounding voice and the
|
// the compensation pending iff this start is a takeover/steal of a sounding voice and the
|
||||||
// caller opted in. The ramp is seeded on the first frame rendered after the restart, from
|
// caller opted in. The ramp is seeded on the first frame rendered after the restart, from
|
||||||
@@ -72,6 +72,7 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
|
|||||||
// the control outright — the predicate is spelled the same way advanceFrame spells it.
|
// the control outright — the predicate is spelled the same way advanceFrame spells it.
|
||||||
preserveRead_ = (pitchEngine_ == PitchEngine::Preserve) && shiftL_.configured();
|
preserveRead_ = (pitchEngine_ == PitchEngine::Preserve) && shiftL_.configured();
|
||||||
rateRatio_ = preserveRead_ ? 1.0 : stretchRate_;
|
rateRatio_ = preserveRead_ ? 1.0 : stretchRate_;
|
||||||
|
keyTrack_ = (keyTrack < 0.0) ? sample.keyTrack : keyTrack;
|
||||||
recomputeBaseRatio();
|
recomputeBaseRatio();
|
||||||
// pitchOffsetRatio_ is a power of 2 and never zero, so this inverse is well-defined — and at
|
// pitchOffsetRatio_ is a power of 2 and never zero, so this inverse is well-defined — and at
|
||||||
// Pitch 0 it is a division by exactly 1.0.
|
// Pitch 0 it is a division by exactly 1.0.
|
||||||
@@ -120,10 +121,9 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
|
|||||||
} else {
|
} else {
|
||||||
// Trigger: play [start, playEnd) where playEnd = start + round(frac*(frames-start)) —
|
// Trigger: play [start, playEnd) where playEnd = start + round(frac*(frames-start)) —
|
||||||
// map/trigger_seam.h's formula, evaluated inline because the engine does not depend on
|
// map/trigger_seam.h's formula, evaluated inline because the engine does not depend on
|
||||||
// map/. The spline fold is effectiveLengthFraction (play_params.h); a second copy of it
|
// map/. The caller's value is ALREADY spline-folded (foldLive does it); the snapshot
|
||||||
// here is what let a stored-but-inert %-knob shorten the bake while the voice played
|
// fallback folds here, because a stored-but-inert %-knob must not shorten the span.
|
||||||
// the whole take.
|
double frac = (lengthFraction < 0.0) ? effectiveLengthFraction(p) : lengthFraction;
|
||||||
double frac = effectiveLengthFraction(p);
|
|
||||||
if (!(frac > 0.0)) frac = 0.0; // %=0 (or a corrupt NaN) -> finishes immediately
|
if (!(frac > 0.0)) frac = 0.0; // %=0 (or a corrupt NaN) -> finishes immediately
|
||||||
if (frac > 1.0) frac = 1.0;
|
if (frac > 1.0) frac = 1.0;
|
||||||
std::int64_t playLen = static_cast<std::int64_t>(
|
std::int64_t playLen = static_cast<std::int64_t>(
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ inline double pitchRatio(int note, int rootNote) {
|
|||||||
// ((note-root)*1.0 is exact in IEEE-754 for an integer-valued double, feeding the same
|
// ((note-root)*1.0 is exact in IEEE-754 for an integer-valued double, feeding the same
|
||||||
// std::pow call); 0.0 means every key plays the root pitch; 2.0 doubles the tracking rate.
|
// std::pow call); 0.0 means every key plays the root pitch; 2.0 doubles the tracking rate.
|
||||||
// At the root note the offset is 0 regardless of keyTrack.
|
// At the root note the offset is 0 regardless of keyTrack.
|
||||||
|
// "Not supplied" for Voice::start's two snapshot-defaulted note-on latches; see start().
|
||||||
|
inline constexpr double kLatchFromSnapshot = -1.0;
|
||||||
|
|
||||||
inline double keyTrackedRatio(int note, int rootNote, double keyTrack) {
|
inline double keyTrackedRatio(int note, int rootNote, double keyTrack) {
|
||||||
const double semis = static_cast<double>(note - rootNote) * keyTrack;
|
const double semis = static_cast<double>(note - rootNote) * keyTrack;
|
||||||
return std::pow(2.0, semis / 12.0);
|
return std::pow(2.0, semis / 12.0);
|
||||||
@@ -124,8 +127,16 @@ public:
|
|||||||
// to consult gets exactly that; VoiceEngine::startVoice is what resolves the real value —
|
// to consult gets exactly that; VoiceEngine::startVoice is what resolves the real value —
|
||||||
// sample.play.playRate is NOT read here, because the published block outranks the snapshot's
|
// sample.play.playRate is NOT read here, because the published block outranks the snapshot's
|
||||||
// possibly-stale copy of it.
|
// possibly-stale copy of it.
|
||||||
|
//
|
||||||
|
// `keyTrack` and `lengthFraction` are the other two members of stretchRate's note-on-latched
|
||||||
|
// class and arrive the same way, for the same structural reason. Negative = not supplied,
|
||||||
|
// which reads the snapshot's own value (sample.keyTrack, effectiveLengthFraction(play)) —
|
||||||
|
// both are non-negative by domain, so the sentinel can never collide with a real one.
|
||||||
|
// VoiceEngine::startVoice always supplies them, resolved from the published block when there
|
||||||
|
// is one; the sentinel is for a caller that has no block to consult.
|
||||||
void start(int note, int velocity, const SampleData& sample, bool declickTakeover = false,
|
void start(int note, int velocity, const SampleData& sample, bool declickTakeover = false,
|
||||||
double stretchRate = 1.0);
|
double stretchRate = 1.0, double keyTrack = kLatchFromSnapshot,
|
||||||
|
double lengthFraction = kLatchFromSnapshot);
|
||||||
|
|
||||||
// Mono legato takeover: re-pitch this active voice to `note` without touching the
|
// Mono legato takeover: re-pitch this active voice to `note` without touching the
|
||||||
// amplitude envelope, read position, or shifter state — pitch moves, no re-attack. Both
|
// amplitude envelope, read position, or shifter state — pitch moves, no re-attack. Both
|
||||||
@@ -201,7 +212,7 @@ private:
|
|||||||
// shifter's transpose. Cold: note-on, legato retune, and a live block, never per frame.
|
// shifter's transpose. Cold: note-on, legato retune, and a live block, never per frame.
|
||||||
void recomputeBaseRatio() {
|
void recomputeBaseRatio() {
|
||||||
if (sample_ == nullptr) return;
|
if (sample_ == nullptr) return;
|
||||||
baseRatio_ = keyTrackedRatio(note_, sample_->rootNote, sample_->keyTrack) *
|
baseRatio_ = keyTrackedRatio(note_, sample_->rootNote, keyTrack_) *
|
||||||
velPitchRatio_ * pitchOffsetRatio_ * rateRatio_;
|
velPitchRatio_ * pitchOffsetRatio_ * rateRatio_;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -710,6 +721,10 @@ private:
|
|||||||
double velPitchRatio_ = 1.0; // the velocity->pitch factor alone; retune re-applies it
|
double velPitchRatio_ = 1.0; // the velocity->pitch factor alone; retune re-applies it
|
||||||
double pitchOffsetRatio_ = 1.0; // the Pitch knob's factor — LIVE, re-applied by applyLive
|
double pitchOffsetRatio_ = 1.0; // the Pitch knob's factor — LIVE, re-applied by applyLive
|
||||||
double rateRatio_ = 1.0; // Rate's factor of the read increment; start() owns when it is 1
|
double rateRatio_ = 1.0; // Rate's factor of the read increment; start() owns when it is 1
|
||||||
|
// Key-track, LATCHED at note-on beside the rate. Held here rather than re-read off the
|
||||||
|
// snapshot so a legato retune and a live block re-apply the note's own value; a published
|
||||||
|
// move reaches the next note only.
|
||||||
|
double keyTrack_ = kKeyTrackDefault;
|
||||||
// Whether this note is ACTUALLY taking the Preserve read — a Preserve voice whose shifters
|
// Whether this note is ACTUALLY taking the Preserve read — a Preserve voice whose shifters
|
||||||
// were never sized falls back to the varispeed one, and the two domains differ. Latched at
|
// were never sized falls back to the varispeed one, and the two domains differ. Latched at
|
||||||
// note-on beside rateRatio_, which start() resolves from the same predicate.
|
// note-on beside rateRatio_, which start() resolves from the same predicate.
|
||||||
|
|||||||
@@ -56,9 +56,14 @@ void VoiceEngine::startVoice(Voice& voice, int note, int velocity) {
|
|||||||
refreshLive();
|
refreshLive();
|
||||||
// THE read of the note-on-latched commit class, and the only one: a published block outranks
|
// THE read of the note-on-latched commit class, and the only one: a published block outranks
|
||||||
// the snapshot's own copy (a live edit deliberately leaves that stale), and applyLive below
|
// the snapshot's own copy (a live edit deliberately leaves that stale), and applyLive below
|
||||||
// never touches the rate — so a Rate move reaches the next note and no sounding one.
|
// touches none of these three — so a move reaches the next note and no sounding one.
|
||||||
const double rate = haveLive_ ? live_.playRate : sample_.play.playRate;
|
const double rate = haveLive_ ? live_.playRate : sample_.play.playRate;
|
||||||
voice.start(note, velocity, sample_, /*declickTakeover=*/takeoverDeclick_, rate);
|
const double keyTrack = haveLive_ ? live_.keyTrack : sample_.keyTrack;
|
||||||
|
// Already spline-folded in the block; the snapshot branch folds here so the two agree.
|
||||||
|
const double lengthFraction =
|
||||||
|
haveLive_ ? live_.lengthFraction : effectiveLengthFraction(sample_.play);
|
||||||
|
voice.start(note, velocity, sample_, /*declickTakeover=*/takeoverDeclick_, rate, keyTrack,
|
||||||
|
lengthFraction);
|
||||||
if (haveLive_) voice.applyLive(live_, /*snap=*/true);
|
if (haveLive_) voice.applyLive(live_, /*snap=*/true);
|
||||||
voice.setStartOrder(nextStartOrder_++);
|
voice.setStartOrder(nextStartOrder_++);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,25 @@
|
|||||||
// not link the bank model and the WAV codec to reach one value struct. `resolvePlay`, which
|
// not link the bank model and the WAV codec to reach one value struct. `resolvePlay`, which
|
||||||
// turns them into the engine's frame domain, stays in sample_map with the rest of the mapping.
|
// turns them into the engine's frame domain, stays in sample_map with the rest of the mapping.
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
#include "core/instrument/engine/play_params.h" // PlayMode / TriggerParams / SplineEnv / …
|
#include "core/instrument/engine/play_params.h" // PlayMode / TriggerParams / SplineEnv / …
|
||||||
|
|
||||||
namespace reasampler::instrument::map {
|
namespace reasampler::instrument::map {
|
||||||
|
|
||||||
using instrument::engine::VelocityCurve;
|
using instrument::engine::VelocityCurve;
|
||||||
|
|
||||||
|
// THE seconds -> frames fold, and the one home for its rounding: resolvePlay resolves the whole
|
||||||
|
// bundle through it, and the audio thread's live patch (param/param_live) resolves one stage
|
||||||
|
// time through it, so a stage time can never land on a different frame depending on the writer.
|
||||||
|
// A non-positive rate yields 0 rather than inventing one; a negative time floors at 0.
|
||||||
|
inline std::int64_t secondsToFrames(double seconds, double sampleRate) {
|
||||||
|
if (!(sampleRate > 0.0)) return 0;
|
||||||
|
double f = seconds * sampleRate;
|
||||||
|
if (!(f > 0.0)) return 0; // also catches NaN
|
||||||
|
return static_cast<std::int64_t>(f + 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
// Daniel's standing ruling: no hardcoded sample rate anywhere in the program. The
|
// Daniel's standing ruling: no hardcoded sample rate anywhere in the program. The
|
||||||
// instrument stores/edits wall-clock performance times (AHDSR A/H/D/R, pitch-env A/D) as
|
// instrument stores/edits wall-clock performance times (AHDSR A/H/D/R, pitch-env A/D) as
|
||||||
// SECONDS, rate-free; the engine receives FRAMES resolved from the LIVE sample rate at
|
// SECONDS, rate-free; the engine receives FRAMES resolved from the LIVE sample rate at
|
||||||
|
|||||||
@@ -215,11 +215,7 @@ PlayParams resolvePlay(const PlaySeconds& stored, int sampleRate) {
|
|||||||
// carries through untouched, already a fraction.
|
// carries through untouched, already a fraction.
|
||||||
assert(sampleRate > 0 && "resolvePlay: sampleRate must be > 0 (programming error)");
|
assert(sampleRate > 0 && "resolvePlay: sampleRate must be > 0 (programming error)");
|
||||||
const double sr = sampleRate > 0 ? static_cast<double>(sampleRate) : 1.0; // 1.0 avoids div-by-zero; assert fires first
|
const double sr = sampleRate > 0 ? static_cast<double>(sampleRate) : 1.0; // 1.0 avoids div-by-zero; assert fires first
|
||||||
const auto secToFrames = [sr](double sec) {
|
const auto secToFrames = [sr](double sec) { return secondsToFrames(sec, sr); };
|
||||||
double f = sec * sr;
|
|
||||||
if (f < 0.0) f = 0.0;
|
|
||||||
return static_cast<std::int64_t>(f + 0.5);
|
|
||||||
};
|
|
||||||
// The one seconds->frames fold for a stored AHD; the fraction and the curves are rate-free.
|
// The one seconds->frames fold for a stored AHD; the fraction and the curves are rate-free.
|
||||||
const auto resolveAhd = [&secToFrames](const AhdSeconds& s) {
|
const auto resolveAhd = [&secToFrames](const AhdSeconds& s) {
|
||||||
AhdParams a;
|
AhdParams a;
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ struct InstrumentParams {
|
|||||||
// exactly 1.0, so already-saved instances are bit-identical. 0.0 = no tracking (every
|
// exactly 1.0, so already-saved instances are bit-identical. 0.0 = no tracking (every
|
||||||
// key plays root pitch); 2.0 = double. Applied in keyTrackedRatio inside both repitch
|
// key plays root pitch); 2.0 = double. Applied in keyTrackedRatio inside both repitch
|
||||||
// engines.
|
// engines.
|
||||||
double keyTrack = 1.0;
|
double keyTrack = kKeyTrackDefault;
|
||||||
|
|
||||||
// Velocity->amp transfer curve: maps note-on MIDI velocity (0..127) to voice amp gain,
|
// Velocity->amp transfer curve: maps note-on MIDI velocity (0..127) to voice amp gain,
|
||||||
// replacing the old fixed linear velocity/127. Default = flat y=1 (Daniel-approved):
|
// replacing the old fixed linear velocity/127. Default = flat y=1 (Daniel-approved):
|
||||||
@@ -215,7 +215,7 @@ struct InstrumentParams {
|
|||||||
struct ResolvedCapture {
|
struct ResolvedCapture {
|
||||||
std::string relativePath; // project-relative; the shell resolves + decodes it
|
std::string relativePath; // project-relative; the shell resolves + decodes it
|
||||||
int rootNote = 60; // effective: override, else bank intrinsic, else 60
|
int rootNote = 60; // effective: override, else bank intrinsic, else 60
|
||||||
double keyTrack = 1.0;
|
double keyTrack = kKeyTrackDefault;
|
||||||
VelocityCurve velocityCurve = VelocityCurve::flat();
|
VelocityCurve velocityCurve = VelocityCurve::flat();
|
||||||
SampleLoop loop; // effective: loopOverride, else bank intrinsic
|
SampleLoop loop; // effective: loopOverride, else bank intrinsic
|
||||||
std::int64_t loopCrossfadeFrames = 0; // instrument-owned; no bank intrinsic to beat
|
std::int64_t loopCrossfadeFrames = 0; // instrument-owned; no bank intrinsic to beat
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ 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.
|
||||||
|
|
||||||
|
**Where an exposed control's value lives is `valueHomeFor`'s answer, and the exposed set is
|
||||||
|
asserted against it.** Two controls sit beside the parameter set rather than in it — master gain
|
||||||
|
(the processor's atomic) and pitch key-track (`InstrumentParams::keyTrack`) — and a promotion
|
||||||
|
whose control has no home would no-op silently in both directions on the host path with nothing
|
||||||
|
to catch it at compile time. That is exactly what happened to id 1000 before the guard existed.
|
||||||
|
|
||||||
## Invariants
|
## Invariants
|
||||||
|
|
||||||
### The id table is FOREVER-FROZEN
|
### The id table is FOREVER-FROZEN
|
||||||
@@ -57,6 +63,13 @@ 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
|
||||||
|
place, allocation-free and lock-free, for the host's `IParameterChanges` queue. It exists
|
||||||
|
because the model layer cannot run there (`PlaySeconds` carries velocity curves and spline
|
||||||
|
contours, so `resolvePlay` allocates) while the queue is delivered there. Every law is called —
|
||||||
|
`ui::storedFromNorm` and `map::secondsToFrames` are the same two the model path uses; what is
|
||||||
|
new is the ROUTING, and that is pinned by an exhaustive equivalence test against the model path
|
||||||
|
over every exposed control rather than by two tables that happen to agree.
|
||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
@@ -68,18 +81,33 @@ 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 reads `1.00` to the host.**
|
- **A curve exponent inside the knob detent but not exactly neutral is NEUTRALIZED by any host
|
||||||
The detent lives in `curve_law`'s norm↔exponent map and the host's only handle is the norm, so
|
touch — a VALUE consequence, not a display one.** The detent lives in `curve_law`'s
|
||||||
the host cannot see an off-detent near-neutral exponent — reachable only by an overlay knot
|
norm↔exponent map and the host's only handle is the norm, so the host reads such an exponent
|
||||||
drag, which writes through `curveFromLevelAt` rather than the knob law. The editor's own label
|
back as `1.00` (the editor's own label reads the stored field and still shows the true value).
|
||||||
deliberately reads the stored field directly and still shows the true value; that divergence is
|
The sharp half is the WRITE: a host write of that norm reaches `ui::setDeckParam` →
|
||||||
structural to VST3, not a formatter defect.
|
`storedFromNorm` → `util::curveFromKnobNorm`, whose ±0.01 detent rewrites the stored exponent
|
||||||
|
to exactly `1.0`. So an off-detent near-neutral exponent set by an overlay knot drag is
|
||||||
|
silently flattened by any host touch or lane pass over that parameter.
|
||||||
|
**Assessed and ACCEPTED, not merely documented:** the alternative is to widen the exposed
|
||||||
|
parameter's law so the detent band is addressable, and §6.3 freezes that law on the first
|
||||||
|
shipped build — a permanent change to twelve parameters' normalization, to preserve a
|
||||||
|
difference the user cannot see on the knob (the detent exists precisely because a drag cannot
|
||||||
|
land on the identity reliably) and cannot hear (the band is ±0.047 of the exponent). Removing
|
||||||
|
the detent from the WRITE path alone would leave the knob unable to reach the identity, which
|
||||||
|
is the defect it was added for. The residual is confined to knot-drawn near-neutral curves.
|
||||||
- **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 filter's four store their position as a `float`, so a not-yet-stored norm can display
|
- **MORPH ALONE can display one digit differently from a not-yet-stored norm.** The filter's four
|
||||||
one digit differently.** A host previewing a value it has sent but that has not round-tripped
|
store their position as a `float`, but cutoff, Q and drive cast the incoming norm to `float`
|
||||||
through the stored float differs from the editor by up to a float ulp; at a value landing
|
*inside* `toPlain`, so `toPlain(n)` and `toPlain(double(float(n)))` are bit-identical and those
|
||||||
exactly on a display rounding boundary that is worth one integer percent on morph. Both
|
three are held to digit-for-digit string equality like everything else. Morph's path is
|
||||||
surfaces read the MODEL in every settled state, so this is a transient of the write itself,
|
full-double (`clamp01(n) * 100`), so the float the model stores and the double the host holds
|
||||||
not a standing divergence — `test_param_format` holds those four to the plain value rather
|
are genuinely different inputs — worth one integer percent at a value landing on a display
|
||||||
than to the string for exactly this reason.
|
rounding boundary. Both surfaces read the MODEL in every settled state, so it is a transient of
|
||||||
|
the write itself, not a standing divergence; `test_param_format` holds morph alone to the plain
|
||||||
|
value rather than to the string.
|
||||||
|
- **A host write the MODEL clamps is not a settled state either.** Trigger length's stored
|
||||||
|
domain is `(0,1]`, so a host norm of 0 comes back as 0.01. `setParamNormalized` caches what the
|
||||||
|
model took, so the host never holds the rejected value — the sweep skips the clamped steps for
|
||||||
|
that reason rather than loosening its comparison.
|
||||||
|
|||||||
@@ -5,13 +5,28 @@ reasampler_test(param_id LINK param_id)
|
|||||||
|
|
||||||
# The norm <-> plain layer. deck_values carries the tapers' full scales and the two field
|
# The norm <-> plain layer. deck_values carries the tapers' full scales and the two field
|
||||||
# resolvers the defaults are read through; filter_params and master_gain are the frozen laws the
|
# resolvers the defaults are read through; filter_params and master_gain are the frozen laws the
|
||||||
# filter's four and the gain report through, CALLED rather than restated.
|
# filter's four and the gain report through, CALLED rather than restated. filter_params rather
|
||||||
|
# than the whole `filter` target: this is the parameter surface, and a link edge from it onto the
|
||||||
|
# per-voice filter KERNEL would put the voice DSP in reach of any future extension-side consumer
|
||||||
|
# of param_format — which root CLAUDE.md's bake invariant forbids.
|
||||||
reasampler_pure_library(param_units
|
reasampler_pure_library(param_units
|
||||||
SOURCES param_units.cpp
|
SOURCES param_units.cpp
|
||||||
LINK PUBLIC deck_values param_taper curve_law master_gain filter)
|
LINK PUBLIC deck_values param_taper curve_law master_gain filter_params)
|
||||||
reasampler_test(param_units LINK param_units param_id)
|
# sample_map for the test alone: the host-vs-editor default agreement reads the two instance
|
||||||
|
# scalars where they LIVE, and one of them is a field of InstrumentParams.
|
||||||
|
reasampler_test(param_units LINK param_units param_id sample_map)
|
||||||
|
|
||||||
reasampler_pure_library(param_format SOURCES param_format.cpp LINK PUBLIC param_units)
|
reasampler_pure_library(param_format SOURCES param_format.cpp LINK PUBLIC param_units)
|
||||||
# param_id is linked for the test only: the one-formatter-two-consumers assertion sweeps the
|
# param_id and sample_map are linked for the test only: the one-formatter-two-consumers assertion
|
||||||
# exposed set, which is identity's answer rather than this module's.
|
# sweeps the exposed set (identity's answer, not this module's) and reads pitch key-track where it
|
||||||
reasampler_test(param_format LINK param_format param_id)
|
# lives, on InstrumentParams.
|
||||||
|
reasampler_test(param_format LINK param_format param_id sample_map)
|
||||||
|
|
||||||
|
# The audio thread's half: the live block plus the two laws it patches through. No engine —
|
||||||
|
# the block is a value, not a thing the voice owns.
|
||||||
|
reasampler_pure_library(param_live
|
||||||
|
SOURCES param_live.cpp
|
||||||
|
LINK PUBLIC deck_values live_params)
|
||||||
|
# sample_map for the test alone: the equivalence assertion drives the MODEL path
|
||||||
|
# (setDeckParam -> resolvePlay -> foldLive) as its reference.
|
||||||
|
reasampler_test(param_live LINK param_live param_id param_units sample_map)
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
// param_live.cpp — see param_live.h. Three field resolvers plus one dispatch; every law is
|
||||||
|
// called, none is restated.
|
||||||
|
|
||||||
|
#include "core/instrument/param/param_live.h"
|
||||||
|
|
||||||
|
#include "core/instrument/map/play_seconds.h" // secondsToFrames (resolvePlay's own fold)
|
||||||
|
#include "core/instrument/ui/deck_values.h" // storedFromNorm (setDeckParam's own map)
|
||||||
|
|
||||||
|
namespace reasampler::instrument::param {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
using engine::LiveValues;
|
||||||
|
|
||||||
|
// The block member a control names, in the same shape deck_values' two field resolvers take:
|
||||||
|
// LOCATION only, no law. Null for a control the block does not carry.
|
||||||
|
std::int64_t* frameField(LiveValues& v, DeckParam deck) {
|
||||||
|
switch (deck) {
|
||||||
|
case DeckParam::kAttack: return &v.adsr.attackFrames;
|
||||||
|
case DeckParam::kHold: return &v.adsr.holdFrames;
|
||||||
|
case DeckParam::kDecay: return &v.adsr.decayFrames;
|
||||||
|
case DeckParam::kRelease: return &v.adsr.releaseFrames;
|
||||||
|
case DeckParam::kTrigAttack: return &v.ampAhd.attackFrames;
|
||||||
|
case DeckParam::kTrigDecay: return &v.ampAhd.decayFrames;
|
||||||
|
case DeckParam::kPitchEnvAttack: return &v.pitchEnv.shape.attackFrames;
|
||||||
|
case DeckParam::kPitchEnvDecay: return &v.pitchEnv.shape.decayFrames;
|
||||||
|
case DeckParam::kFilterEnvAttack: return &v.filterEnv.attackFrames;
|
||||||
|
case DeckParam::kFilterEnvHold: return &v.filterEnv.holdFrames;
|
||||||
|
case DeckParam::kFilterEnvDecay: return &v.filterEnv.decayFrames;
|
||||||
|
case DeckParam::kFilterEnvRelease: return &v.filterEnv.releaseFrames;
|
||||||
|
case DeckParam::kFilterTrigAttack: return &v.filterAhd.attackFrames;
|
||||||
|
case DeckParam::kFilterTrigDecay: return &v.filterAhd.decayFrames;
|
||||||
|
default: return nullptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The filter's four, which store their normalized position as float in the block exactly as the
|
||||||
|
// parameter set stores it.
|
||||||
|
float* normField(LiveValues& v, DeckParam deck) {
|
||||||
|
switch (deck) {
|
||||||
|
case DeckParam::kFilterMorph: return &v.filterSettings.morphNorm;
|
||||||
|
case DeckParam::kFilterCutoff: return &v.filterSettings.cutoffNorm;
|
||||||
|
case DeckParam::kFilterQ: return &v.filterSettings.resonanceNorm;
|
||||||
|
case DeckParam::kFilterDrive: return &v.filterSettings.driveNorm;
|
||||||
|
default: return nullptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
double* doubleField(LiveValues& v, DeckParam deck) {
|
||||||
|
switch (deck) {
|
||||||
|
case DeckParam::kSustain: return &v.adsr.sustainLevel;
|
||||||
|
case DeckParam::kAttackCurve: return &v.adsr.attackCurve;
|
||||||
|
case DeckParam::kDecayCurve: return &v.adsr.decayCurve;
|
||||||
|
case DeckParam::kReleaseCurve: return &v.adsr.releaseCurve;
|
||||||
|
case DeckParam::kTrigHold: return &v.ampAhd.holdFraction;
|
||||||
|
case DeckParam::kTrigAttackCurve: return &v.ampAhd.attackCurve;
|
||||||
|
case DeckParam::kTrigDecayCurve: return &v.ampAhd.decayCurve;
|
||||||
|
case DeckParam::kPitchEnvHold: return &v.pitchEnv.shape.holdFraction;
|
||||||
|
case DeckParam::kPitchEnvAttackCurve: return &v.pitchEnv.shape.attackCurve;
|
||||||
|
case DeckParam::kPitchEnvDecayCurve: return &v.pitchEnv.shape.decayCurve;
|
||||||
|
case DeckParam::kPitchEnvDepth: return &v.pitchEnv.peakSemitones;
|
||||||
|
case DeckParam::kFilterEnvSustain: return &v.filterEnv.sustainLevel;
|
||||||
|
case DeckParam::kFilterEnvAttackCurve: return &v.filterEnv.attackCurve;
|
||||||
|
case DeckParam::kFilterEnvDecayCurve: return &v.filterEnv.decayCurve;
|
||||||
|
case DeckParam::kFilterEnvReleaseCurve: return &v.filterEnv.releaseCurve;
|
||||||
|
case DeckParam::kFilterTrigHold: return &v.filterAhd.holdFraction;
|
||||||
|
case DeckParam::kFilterTrigAttackCurve: return &v.filterAhd.attackCurve;
|
||||||
|
case DeckParam::kFilterTrigDecayCurve: return &v.filterAhd.decayCurve;
|
||||||
|
case DeckParam::kFilterModAmt: return &v.filterModAmount;
|
||||||
|
case DeckParam::kFilterVel: return &v.filterVelAmount;
|
||||||
|
case DeckParam::kFilterKeyTrack: return &v.filterKeyTrack;
|
||||||
|
case DeckParam::kRate: return &v.playRate;
|
||||||
|
case DeckParam::kPitch: return &v.pitchOffsetSemitones;
|
||||||
|
case DeckParam::kKeyTrack: return &v.keyTrack;
|
||||||
|
default: return nullptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
bool applyLiveParam(LiveValues& block, DeckParam deck, double normalized, int sampleRate) {
|
||||||
|
// Trigger length is the one control the block does not carry verbatim: what it publishes is
|
||||||
|
// the SPLINE-FOLDED fraction, so a write while a contour is active must be inert here for
|
||||||
|
// the same reason the knob is inert in the editor.
|
||||||
|
if (deck == DeckParam::kTrigLength) {
|
||||||
|
if (!block.splineActive) block.lengthFraction = ui::storedFromNorm(deck, normalized);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (std::int64_t* f = frameField(block, deck)) {
|
||||||
|
*f = map::secondsToFrames(ui::storedFromNorm(deck, normalized),
|
||||||
|
static_cast<double>(sampleRate));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (float* f = normField(block, deck)) {
|
||||||
|
*f = static_cast<float>(ui::storedFromNorm(deck, normalized));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (double* f = doubleField(block, deck)) {
|
||||||
|
*f = ui::storedFromNorm(deck, normalized);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace reasampler::instrument::param
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// param_live.h — the AUDIO-THREAD half of a host parameter write: one exposed control patched
|
||||||
|
// into the live block, in place, with no allocation and no lock. It exists because the model
|
||||||
|
// layer cannot run on the audio thread (PlaySeconds carries velocity curves and spline contours,
|
||||||
|
// so resolvePlay allocates), while `IParameterChanges` is delivered there.
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "core/instrument/engine/live_params.h"
|
||||||
|
#include "core/instrument/ui/deck_groups.h" // DeckParam
|
||||||
|
|
||||||
|
namespace reasampler::instrument::param {
|
||||||
|
|
||||||
|
using ui::DeckParam;
|
||||||
|
|
||||||
|
// Writes `normalized` for `deck` into `block`. RT-SAFE: no allocation, no lock, no transcendental
|
||||||
|
// beyond the taper's own. Returns false for a control this block does not carry — master gain,
|
||||||
|
// which reaches the audio as the processor's own atomic, and anything unexposed.
|
||||||
|
//
|
||||||
|
// The value laws are NOT restated here: `ui::storedFromNorm` is the same norm -> stored map
|
||||||
|
// `setDeckParam` writes with, and `map::secondsToFrames` the same fold `resolvePlay` uses. What
|
||||||
|
// IS new is the routing — which member of the block a control names — and that is pinned by an
|
||||||
|
// exhaustive equivalence test against the model path over every exposed control, rather than by
|
||||||
|
// two tables that happen to agree.
|
||||||
|
//
|
||||||
|
// `sampleRate` is the rate the loaded capture was BUILT at (the processor's builtSampleRate_),
|
||||||
|
// so a patched stage time lands on exactly the frames the build would have resolved.
|
||||||
|
bool applyLiveParam(engine::LiveValues& block, DeckParam deck, double normalized, int sampleRate);
|
||||||
|
|
||||||
|
} // namespace reasampler::instrument::param
|
||||||
@@ -66,10 +66,49 @@ UnitKind unitKindFor(DeckParam deck) {
|
|||||||
return UnitKind::Decibels;
|
return UnitKind::Decibels;
|
||||||
case DeckParam::kFilterCutoff:
|
case DeckParam::kFilterCutoff:
|
||||||
return UnitKind::Hertz;
|
return UnitKind::Hertz;
|
||||||
default:
|
// The twelve curve exponents and the filter's two dimensionless tone controls. Listed
|
||||||
// The twelve curve exponents, Q and drive. Everything else has no row at all.
|
// rather than defaulted, and everything with no parameter row at all is listed with
|
||||||
|
// them: a `default:` here would let a control promoted later inherit Dimensionless
|
||||||
|
// silently, and §6.3 freezes an exposed parameter's normalization on the first shipped
|
||||||
|
// build — so the wrong answer would be permanent rather than correctable.
|
||||||
|
case DeckParam::kFilterQ:
|
||||||
|
case DeckParam::kFilterDrive:
|
||||||
|
case DeckParam::kAttackCurve:
|
||||||
|
case DeckParam::kDecayCurve:
|
||||||
|
case DeckParam::kReleaseCurve:
|
||||||
|
case DeckParam::kTrigAttackCurve:
|
||||||
|
case DeckParam::kTrigDecayCurve:
|
||||||
|
case DeckParam::kPitchEnvAttackCurve:
|
||||||
|
case DeckParam::kPitchEnvDecayCurve:
|
||||||
|
case DeckParam::kFilterEnvAttackCurve:
|
||||||
|
case DeckParam::kFilterEnvDecayCurve:
|
||||||
|
case DeckParam::kFilterEnvReleaseCurve:
|
||||||
|
case DeckParam::kFilterTrigAttackCurve:
|
||||||
|
case DeckParam::kFilterTrigDecayCurve:
|
||||||
|
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 UnitKind::Dimensionless;
|
return UnitKind::Dimensionless;
|
||||||
}
|
}
|
||||||
|
return UnitKind::Dimensionless; // unreachable for a valid enumerator; silences a warning.
|
||||||
}
|
}
|
||||||
|
|
||||||
const char* unitStringFor(DeckParam deck) {
|
const char* unitStringFor(DeckParam deck) {
|
||||||
@@ -192,9 +231,18 @@ double toNormalized(DeckParam deck, double plain) {
|
|||||||
return plain;
|
return plain;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool storesNormalized(DeckParam deck) {
|
ValueHome valueHomeFor(DeckParam deck) {
|
||||||
PlaySeconds defaults;
|
PlaySeconds defaults;
|
||||||
return ui::deckFloatField(deck, defaults) != nullptr;
|
if (ui::deckFloatField(deck, defaults)) return ValueHome::ParamSetNorm;
|
||||||
|
if (ui::deckDoubleField(deck, defaults)) return ValueHome::ParamSet;
|
||||||
|
if (deck == DeckParam::kMasterGain || deck == DeckParam::kKeyTrack) {
|
||||||
|
return ValueHome::InstanceScalar;
|
||||||
|
}
|
||||||
|
return ValueHome::None;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool storesNormalized(DeckParam deck) {
|
||||||
|
return valueHomeFor(deck) == ValueHome::ParamSetNorm;
|
||||||
}
|
}
|
||||||
|
|
||||||
double defaultPlain(DeckParam deck) {
|
double defaultPlain(DeckParam deck) {
|
||||||
@@ -204,10 +252,17 @@ double defaultPlain(DeckParam deck) {
|
|||||||
if (const float* stored = ui::deckFloatField(deck, defaults)) {
|
if (const float* stored = ui::deckFloatField(deck, defaults)) {
|
||||||
return toPlain(deck, static_cast<double>(*stored));
|
return toPlain(deck, static_cast<double>(*stored));
|
||||||
}
|
}
|
||||||
|
// The two instance scalars, whose default is not a field of PlaySeconds.
|
||||||
if (deck == DeckParam::kMasterGain) return 0.0; // unity, and the sharpest exactness case
|
if (deck == DeckParam::kMasterGain) return 0.0; // unity, and the sharpest exactness case
|
||||||
|
if (deck == DeckParam::kKeyTrack) {
|
||||||
|
return kKeyTrackDefault * kPercentFullScale;
|
||||||
|
}
|
||||||
const double* field = ui::deckDoubleField(deck, defaults);
|
const double* field = ui::deckDoubleField(deck, defaults);
|
||||||
if (!field) return 0.0;
|
if (!field) return 0.0;
|
||||||
switch (unitKindFor(deck)) {
|
switch (unitKindFor(deck)) {
|
||||||
|
// Time converts seconds -> ms here and ms -> seconds in toNormalized, so its exactness
|
||||||
|
// additionally rests on x*1000/1000 == x — param_taper guarantees its quantum in SECONDS,
|
||||||
|
// not in ms. It holds for today's three Time defaults; a new one is a case to re-check.
|
||||||
case UnitKind::Time: return *field * 1000.0; // stored seconds
|
case UnitKind::Time: return *field * 1000.0; // stored seconds
|
||||||
case UnitKind::PercentUnipolar: return *field * kPercentFullScale;
|
case UnitKind::PercentUnipolar: return *field * kPercentFullScale;
|
||||||
case UnitKind::PercentKeyTrack: return *field * kPercentFullScale; // stored 0..2
|
case UnitKind::PercentKeyTrack: return *field * kPercentFullScale; // stored 0..2
|
||||||
|
|||||||
@@ -48,6 +48,17 @@ 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);
|
||||||
|
|
||||||
|
// WHERE a control's value actually lives. The host's read and write paths branch on this, and
|
||||||
|
// the exposed set is asserted against it: a control promoted into the list with no home would
|
||||||
|
// otherwise no-op silently in BOTH directions, with nothing to catch it at compile time.
|
||||||
|
enum class ValueHome {
|
||||||
|
None, // not a scalar control at all — a toggle, a radio, a curve-popup cell
|
||||||
|
ParamSetNorm, // the filter's four: the stored double IS the normalized position
|
||||||
|
ParamSet, // every other knob the parameter set carries
|
||||||
|
InstanceScalar, // beside the parameter set: master gain, and the pitch key-track scalar
|
||||||
|
};
|
||||||
|
ValueHome valueHomeFor(DeckParam deck);
|
||||||
|
|
||||||
// The filter's four tone controls STORE their normalized position (payload v9), so their default
|
// The filter's four tone controls STORE their normalized position (payload v9), so their default
|
||||||
// normalized value is that stored double verbatim and no taper participates in a host's
|
// normalized value is that stored double verbatim and no taper participates in a host's
|
||||||
// reset-to-default. Reporting Hz / Q / drive depth for them means CALLING their frozen laws, not
|
// reset-to-default. Reporting Hz / Q / drive depth for them means CALLING their frozen laws, not
|
||||||
|
|||||||
@@ -201,8 +201,10 @@ DeckParam curveParamFor(DeckParam knob) {
|
|||||||
|
|
||||||
LiveCommit deckParamCommit(DeckParam id) {
|
LiveCommit deckParamCommit(DeckParam id) {
|
||||||
switch (id) {
|
switch (id) {
|
||||||
// The one note-on-latched control; the header owns why.
|
// The note-on-latched controls; the header owns why each one latches.
|
||||||
case DeckParam::kRate:
|
case DeckParam::kRate:
|
||||||
|
case DeckParam::kKeyTrack:
|
||||||
|
case DeckParam::kTrigLength:
|
||||||
return LiveCommit::NoteOnLatched;
|
return LiveCommit::NoteOnLatched;
|
||||||
// Live by the tier's own definition — one atomic store the audio thread picks up at the
|
// Live by the tier's own definition — one atomic store the audio thread picks up at the
|
||||||
// next block, no bridge read and no re-decode. It reaches the audio beside the live
|
// next block, no bridge read and no re-decode. It reaches the audio beside the live
|
||||||
@@ -256,9 +258,7 @@ LiveCommit deckParamCommit(DeckParam id) {
|
|||||||
// to non-live. Reasons live in the header.
|
// to non-live. Reasons live in the header.
|
||||||
case DeckParam::kPlayMode:
|
case DeckParam::kPlayMode:
|
||||||
case DeckParam::kPitchEngine:
|
case DeckParam::kPitchEngine:
|
||||||
case DeckParam::kTrigLength:
|
|
||||||
case DeckParam::kPitchEnvEnable:
|
case DeckParam::kPitchEnvEnable:
|
||||||
case DeckParam::kKeyTrack:
|
|
||||||
case DeckParam::kFilterEnable:
|
case DeckParam::kFilterEnable:
|
||||||
case DeckParam::kAmpVelCurve:
|
case DeckParam::kAmpVelCurve:
|
||||||
case DeckParam::kPitchVelCurve:
|
case DeckParam::kPitchVelCurve:
|
||||||
|
|||||||
@@ -89,6 +89,49 @@ double deckParamNorm(DeckParam id, const PlaySeconds& play) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
double storedFromNorm(DeckParam id, double norm) {
|
||||||
|
switch (id) {
|
||||||
|
// The filter's four STORE their normalized position (payload v9), so the identity IS
|
||||||
|
// their law — deckFloatField's four, and the reason it is a separate resolver.
|
||||||
|
case DeckParam::kFilterMorph:
|
||||||
|
case DeckParam::kFilterCutoff:
|
||||||
|
case DeckParam::kFilterQ:
|
||||||
|
case DeckParam::kFilterDrive:
|
||||||
|
return clamp01(norm);
|
||||||
|
case DeckParam::kRate:
|
||||||
|
return rateRatioFromNorm(norm, kRateMinRatio, kRateMaxRatio);
|
||||||
|
case DeckParam::kPitch:
|
||||||
|
case DeckParam::kPitchEnvDepth:
|
||||||
|
return depthSemitonesFromNorm(norm, kPitchDepthMaxSemis);
|
||||||
|
case DeckParam::kFilterModAmt:
|
||||||
|
case DeckParam::kFilterVel:
|
||||||
|
return deckBipolarFromNorm(norm);
|
||||||
|
case DeckParam::kKeyTrack:
|
||||||
|
case DeckParam::kFilterKeyTrack:
|
||||||
|
return keyTrackFromNorm(norm);
|
||||||
|
case DeckParam::kTrigLength:
|
||||||
|
// lengthFraction is (0,1]; a small floor so a zero-length trigger never plays
|
||||||
|
// nothing.
|
||||||
|
return (std::max)(0.01, clamp01(norm));
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// The rest are decided by the display unit alone, which is what makes the fourteen stage
|
||||||
|
// times and the twelve curve dials one line each rather than twenty-six.
|
||||||
|
switch (deckParamUnit(id)) {
|
||||||
|
case UnitCategory::Milliseconds: return timeSecondsFromNorm(norm);
|
||||||
|
case UnitCategory::Exponent: return util::curveFromKnobNorm(norm);
|
||||||
|
case UnitCategory::Percent: return clamp01(norm); // hold fractions, sustain levels
|
||||||
|
// Decibels is master gain, whose stored value is a LINEAR gain the processor owns
|
||||||
|
// rather than a field of the parameter set; the two enums above are handled by id.
|
||||||
|
case UnitCategory::Semitones:
|
||||||
|
case UnitCategory::Decibels:
|
||||||
|
case UnitCategory::None:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return norm;
|
||||||
|
}
|
||||||
|
|
||||||
void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment) {
|
void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment) {
|
||||||
switch (id) {
|
switch (id) {
|
||||||
case DeckParam::kPlayMode:
|
case DeckParam::kPlayMode:
|
||||||
@@ -112,88 +155,24 @@ void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment) {
|
|||||||
case DeckParam::kPitchEngine:
|
case DeckParam::kPitchEngine:
|
||||||
play.pitchEngine = (segment == 1) ? PitchEngine::Preserve : PitchEngine::Varispeed;
|
play.pitchEngine = (segment == 1) ? PitchEngine::Preserve : PitchEngine::Varispeed;
|
||||||
break;
|
break;
|
||||||
case DeckParam::kRate:
|
|
||||||
play.playRate = rateRatioFromNorm(value, kRateMinRatio, kRateMaxRatio); break;
|
|
||||||
case DeckParam::kPitch:
|
|
||||||
play.pitchOffsetSemitones = depthSemitonesFromNorm(value, kPitchDepthMaxSemis); break;
|
|
||||||
case DeckParam::kAttack: play.adsr.attackSeconds = timeSecondsFromNorm(value); break;
|
|
||||||
case DeckParam::kHold: play.adsr.holdSeconds = timeSecondsFromNorm(value); break;
|
|
||||||
case DeckParam::kDecay: play.adsr.decaySeconds = timeSecondsFromNorm(value); break;
|
|
||||||
case DeckParam::kSustain: play.adsr.sustainLevel = clamp01(value); break;
|
|
||||||
case DeckParam::kRelease: play.adsr.releaseSeconds = timeSecondsFromNorm(value); break;
|
|
||||||
case DeckParam::kAttackCurve: play.adsr.attackCurve = util::curveFromKnobNorm(value); break;
|
|
||||||
case DeckParam::kDecayCurve: play.adsr.decayCurve = util::curveFromKnobNorm(value); break;
|
|
||||||
case DeckParam::kReleaseCurve: play.adsr.releaseCurve = util::curveFromKnobNorm(value); break;
|
|
||||||
case DeckParam::kTrigLength:
|
|
||||||
// lengthFraction is (0,1]; keep a small floor so a zero-length trigger never plays
|
|
||||||
// nothing.
|
|
||||||
play.trigger.lengthFraction = (std::max)(0.01, clamp01(value));
|
|
||||||
break;
|
|
||||||
case DeckParam::kTrigAttack: play.trigAhd.attackSeconds = timeSecondsFromNorm(value); break;
|
|
||||||
case DeckParam::kTrigHold: play.trigAhd.holdFraction = clamp01(value); break;
|
|
||||||
case DeckParam::kTrigDecay: play.trigAhd.decaySeconds = timeSecondsFromNorm(value); break;
|
|
||||||
case DeckParam::kTrigAttackCurve:
|
|
||||||
play.trigAhd.attackCurve = util::curveFromKnobNorm(value); break;
|
|
||||||
case DeckParam::kTrigDecayCurve:
|
|
||||||
play.trigAhd.decayCurve = util::curveFromKnobNorm(value); break;
|
|
||||||
case DeckParam::kPitchEnvEnable: play.pitchEnv.enabled = (segment == 1); break;
|
case DeckParam::kPitchEnvEnable: play.pitchEnv.enabled = (segment == 1); break;
|
||||||
case DeckParam::kPitchEnvAttack:
|
|
||||||
play.pitchEnv.shape.attackSeconds = timeSecondsFromNorm(value); break;
|
|
||||||
case DeckParam::kPitchEnvHold:
|
|
||||||
play.pitchEnv.shape.holdFraction = clamp01(value); break;
|
|
||||||
case DeckParam::kPitchEnvDecay:
|
|
||||||
play.pitchEnv.shape.decaySeconds = timeSecondsFromNorm(value); break;
|
|
||||||
case DeckParam::kPitchEnvAttackCurve:
|
|
||||||
play.pitchEnv.shape.attackCurve = util::curveFromKnobNorm(value); break;
|
|
||||||
case DeckParam::kPitchEnvDecayCurve:
|
|
||||||
play.pitchEnv.shape.decayCurve = util::curveFromKnobNorm(value); break;
|
|
||||||
case DeckParam::kPitchEnvDepth:
|
|
||||||
play.pitchEnv.peakSemitones = depthSemitonesFromNorm(value, kPitchDepthMaxSemis);
|
|
||||||
break;
|
|
||||||
case DeckParam::kFilterEnable: play.filter.enabled = (segment == 1); break;
|
case DeckParam::kFilterEnable: play.filter.enabled = (segment == 1); break;
|
||||||
case DeckParam::kFilterLaw:
|
case DeckParam::kFilterLaw:
|
||||||
play.filter.settings.morphLaw =
|
play.filter.settings.morphLaw =
|
||||||
(segment == 1) ? MorphLaw::HighNotchLow : MorphLaw::HighBandLow;
|
(segment == 1) ? MorphLaw::HighNotchLow : MorphLaw::HighBandLow;
|
||||||
break;
|
break;
|
||||||
case DeckParam::kFilterMorph:
|
default:
|
||||||
play.filter.settings.morphNorm = static_cast<float>(clamp01(value)); break;
|
// Every knob: the one norm -> stored law, into the one field the control names.
|
||||||
case DeckParam::kFilterCutoff:
|
// Both halves are shared with the audio thread's live patch (param/param_live), so
|
||||||
play.filter.settings.cutoffNorm = static_cast<float>(clamp01(value)); break;
|
// a control cannot take a different taper or land in a different field depending on
|
||||||
case DeckParam::kFilterQ:
|
// which surface wrote it. A toggle or a value living outside PlaySeconds resolves to
|
||||||
play.filter.settings.resonanceNorm = static_cast<float>(clamp01(value)); break;
|
// neither field and falls through untouched.
|
||||||
case DeckParam::kFilterDrive:
|
if (float* f = deckFloatField(id, play)) {
|
||||||
play.filter.settings.driveNorm = static_cast<float>(clamp01(value)); break;
|
*f = static_cast<float>(storedFromNorm(id, value));
|
||||||
case DeckParam::kFilterModAmt: play.filter.modAmount = deckBipolarFromNorm(value); break;
|
} else if (double* d = deckDoubleField(id, play)) {
|
||||||
case DeckParam::kFilterVel: play.filter.velAmount = deckBipolarFromNorm(value); break;
|
*d = storedFromNorm(id, value);
|
||||||
case DeckParam::kFilterKeyTrack:
|
}
|
||||||
play.filter.keyTrack = clamp01(value) * kKeyTrackMax; break;
|
break;
|
||||||
case DeckParam::kFilterEnvAttack:
|
|
||||||
play.filter.env.attackSeconds = timeSecondsFromNorm(value); break;
|
|
||||||
case DeckParam::kFilterEnvHold:
|
|
||||||
play.filter.env.holdSeconds = timeSecondsFromNorm(value); break;
|
|
||||||
case DeckParam::kFilterEnvDecay:
|
|
||||||
play.filter.env.decaySeconds = timeSecondsFromNorm(value); break;
|
|
||||||
case DeckParam::kFilterEnvSustain:
|
|
||||||
play.filter.env.sustainLevel = clamp01(value); break;
|
|
||||||
case DeckParam::kFilterEnvRelease:
|
|
||||||
play.filter.env.releaseSeconds = timeSecondsFromNorm(value); break;
|
|
||||||
case DeckParam::kFilterEnvAttackCurve:
|
|
||||||
play.filter.env.attackCurve = util::curveFromKnobNorm(value); break;
|
|
||||||
case DeckParam::kFilterEnvDecayCurve:
|
|
||||||
play.filter.env.decayCurve = util::curveFromKnobNorm(value); break;
|
|
||||||
case DeckParam::kFilterEnvReleaseCurve:
|
|
||||||
play.filter.env.releaseCurve = util::curveFromKnobNorm(value); break;
|
|
||||||
case DeckParam::kFilterTrigAttack:
|
|
||||||
play.filter.trigEnv.attackSeconds = timeSecondsFromNorm(value); break;
|
|
||||||
case DeckParam::kFilterTrigHold:
|
|
||||||
play.filter.trigEnv.holdFraction = clamp01(value); break;
|
|
||||||
case DeckParam::kFilterTrigDecay:
|
|
||||||
play.filter.trigEnv.decaySeconds = timeSecondsFromNorm(value); break;
|
|
||||||
case DeckParam::kFilterTrigAttackCurve:
|
|
||||||
play.filter.trigEnv.attackCurve = util::curveFromKnobNorm(value); break;
|
|
||||||
case DeckParam::kFilterTrigDecayCurve:
|
|
||||||
play.filter.trigEnv.decayCurve = util::curveFromKnobNorm(value); break;
|
|
||||||
default: break;
|
|
||||||
}
|
}
|
||||||
// ONE normalization point for every control that can flip splineActive — a mode toggle
|
// ONE normalization point for every control that can flip splineActive — a mode toggle
|
||||||
// (above) or an enable toggle (kPitchEnvEnable/kFilterEnable), whose enabling can make an
|
// (above) or an enable toggle (kPitchEnvEnable/kFilterEnable), whose enabling can make an
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
#include "core/instrument/ui/deck_groups.h" // DeckParam
|
#include "core/instrument/ui/deck_groups.h" // DeckParam
|
||||||
#include "core/instrument/ui/envelope_overlay.h" // kGateStageMaxSeconds
|
#include "core/instrument/ui/envelope_overlay.h" // kGateStageMaxSeconds
|
||||||
#include "core/instrument/ui/param_taper.h" // UnitCategory + the shared tapers
|
#include "core/instrument/ui/param_taper.h" // UnitCategory + the shared tapers
|
||||||
|
#include "core/util/clamp01.h"
|
||||||
|
|
||||||
namespace reasampler::instrument::ui {
|
namespace reasampler::instrument::ui {
|
||||||
|
|
||||||
@@ -28,6 +29,13 @@ 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),
|
||||||
|
// so its two conversions cannot ride the PlaySeconds binding below. One home for them anyway:
|
||||||
|
// the editor knob, the host's write path and the live fold would otherwise each spell the
|
||||||
|
// division out.
|
||||||
|
inline double keyTrackFromNorm(double norm) { return util::clamp01(norm) * kKeyTrackMax; }
|
||||||
|
inline double keyTrackNormFrom(double keyTrack) { return util::clamp01(keyTrack / kKeyTrackMax); }
|
||||||
|
|
||||||
// Rate's range: ALIASES of the stretcher's own measured ratio bounds, so the knob's ends are the
|
// Rate's range: ALIASES of the stretcher's own measured ratio bounds, so the knob's ends are the
|
||||||
// engine's clamp rather than a second opinion of it. The taper takes them as arguments for the
|
// engine's clamp rather than a second opinion of it. The taper takes them as arguments for the
|
||||||
// same reason the depth taper takes its throw — engine/time_stretch.h owns the numbers.
|
// same reason the depth taper takes its throw — engine/time_stretch.h owns the numbers.
|
||||||
@@ -41,6 +49,12 @@ inline constexpr double kRateMaxRatio = engine::kStretchRateMax;
|
|||||||
// shell reads those from the processor.
|
// shell reads those from the processor.
|
||||||
double deckParamNorm(DeckParam id, const PlaySeconds& play);
|
double deckParamNorm(DeckParam id, const PlaySeconds& play);
|
||||||
|
|
||||||
|
// The STORED value a knob's normalized position maps to — the norm -> value half of the binding
|
||||||
|
// on its own, because the audio thread needs it without a PlaySeconds to write into
|
||||||
|
// (`param/param_live`). setDeckParam IS this composed with the field lookup below, so the two
|
||||||
|
// cannot carry different tapers. Answers `norm` unchanged for a control with no stored scalar.
|
||||||
|
double storedFromNorm(DeckParam id, double norm);
|
||||||
|
|
||||||
// Applies a committed interaction: a knob's normalized `value`, or a toggle's `segment` (0/1).
|
// Applies a committed interaction: a knob's normalized `value`, or a toggle's `segment` (0/1).
|
||||||
// Mutates `play` in place, touching exactly the one field the control names.
|
// Mutates `play` in place, touching exactly the one field the control names.
|
||||||
void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment);
|
void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment);
|
||||||
|
|||||||
@@ -98,22 +98,34 @@ pure half — the frozen id table, the exposed set, the plain-value layer, the f
|
|||||||
next touch re-imposing, a superseded value. Master gain has its own funnel
|
next touch re-imposing, a superseded value. Master gain has its own funnel
|
||||||
(`setMasterGainLinear`) because it is the one exposed control that does not ride the
|
(`setMasterGainLinear`) because it is the one exposed control that does not ride the
|
||||||
parameter set.
|
parameter set.
|
||||||
- **`setState` ordering against the host's first parameter block is irrelevant by
|
- **BOTH delivery channels are serviced, and the audio-side one is the normative one.**
|
||||||
construction.** There is one model and one funnel per control, so whichever writes last wins
|
`IEditController::setParamNormalized` is the CONTROLLER channel — the SDK says a controller
|
||||||
and the host's display follows the model either way — the ordering is not assumed, it is
|
"should update the according GUI element(s) only" there, so nothing about the audio may depend
|
||||||
removed as a question.
|
on a host calling it. `ProcessData::inputParameterChanges` is the AUDIO channel, and the SDK's
|
||||||
- **`process()` reads no parameter queue and is unchanged by the parameter surface.** A host
|
own single-component sample (`public.sdk/samples/vst/again/source/againsimple.cpp`) drains it
|
||||||
write arrives on the UI/main thread and reaches the audio thread through the SAME live block
|
in `process()` while also implementing `setParamNormalized`. We do both, for the same reason.
|
||||||
the editor's knobs publish into, observed once per `render()` — block boundaries, last write
|
- **The audio thread is the sole writer of the block the ENGINE reads.** Two `LiveParams`
|
||||||
wins. `[verify — DAW]` that REAPER delivers automation to a single-component plug-in through
|
blocks: the model's publishers (editor commits, reload, `setState`) write `liveParams_` off
|
||||||
`IEditController::setParamNormalized` and not through `ProcessData::inputParameterChanges`
|
the audio thread and may allocate on the way; `process()` merges that block with the host's
|
||||||
alone; if it is the latter only, an RT-safe drain is required and `process()` is where it
|
automation points into `automationLive_`, which is what `SampleData::live` points at. Two
|
||||||
would have to land.
|
blocks rather than one because the seqlock's single-writer contract is load-bearing and the two
|
||||||
|
writers genuinely differ in thread. The merge republishes ONLY when either side moved, so a
|
||||||
|
block carrying neither costs one relaxed load and the engine's read shape is unchanged.
|
||||||
|
- **The automation values fold back into the model on the UI thread** (`drainAutomationToModel`,
|
||||||
|
called from `getState`, the editor's sync tick, and the bake's reload tail). The blob is
|
||||||
|
authoritative, so a value that never came back would be lost on save. The fold is suppressed
|
||||||
|
from notifying the host — the values came FROM it, and echoing them would let a lane in write
|
||||||
|
mode re-record its own playback.
|
||||||
|
- **`setState` does not need an ordering guarantee against the host's first parameter block.**
|
||||||
|
An automation point held by the audio thread is re-applied over every merge, so a written lane
|
||||||
|
outranks the restore whichever way round the two arrive — which is VST3's own rule, not a race
|
||||||
|
we lost.
|
||||||
- **`IMidiMapping` is deliberately NOT implemented** — no conventional CC names most of what
|
- **`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
|
||||||
REAPER's own per-parameter MIDI learn covers the case without freezing anything.
|
`[verify — DAW]` REAPER's own per-parameter MIDI learn is expected to cover the case without
|
||||||
`IParameterFunctionName` and `IAutomationState` are assessed and not implemented; the reasons
|
freezing anything. `IParameterFunctionName` and `IAutomationState` are assessed and not
|
||||||
are in the product spec and are not re-surveyed here.
|
implemented — `bake/CLAUDE.md` owns the `IAutomationState` reasoning, at its one consequence
|
||||||
|
site.
|
||||||
|
|
||||||
**Non-goals / guardrails.**
|
**Non-goals / guardrails.**
|
||||||
- The instrument never captures and never inserts into the arrange. Playback is a
|
- The instrument never captures and never inserts into the arrange. Playback is a
|
||||||
@@ -151,6 +163,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`,
|
||||||
|
structural heuristic 1), on the same footing as `voice.h`'s: it is ONE class declaration, so
|
||||||
|
the seam the heuristic asks for does not exist — a split would be an arbitrary bisection, and
|
||||||
|
the implementation is already split across three TUs on its real seams. Its bulk is the
|
||||||
|
drain-slot proof, the RT-discipline constraints and the two-block automation contract, all of
|
||||||
|
which the comment conventions name as keep-worthy. Not silent overshoot.
|
||||||
|
|
||||||
- **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
|
||||||
nested inside a mouse handler with `SetCapture` held, while the invoked action re-points
|
nested inside a mouse handler with `SetCapture` held, while the invoked action re-points
|
||||||
|
|||||||
@@ -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_id param_units param_format param_live
|
||||||
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
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
// editor_controls.cpp — the ReaSamplerEditor's parameter plumbing: the band-stack layout
|
// editor_controls.cpp — the ReaSamplerEditor's parameter plumbing: the band-stack layout
|
||||||
// resolve every paint/hit-test path shares, the shell's half of the control-value binding (the
|
// resolve every paint/hit-test path shares, the shell's half of the control-value binding (the
|
||||||
// per-instance controls the parameter set does not carry — key-track, voice count, master gain,
|
// per-instance controls the parameter set does not carry — key-track, voice count, master gain,
|
||||||
// preview velocity — plus the value labels), and the node-drag clamp bounds. The parameter-set
|
// preview velocity — plus each knob's plain value and its label), and the node-drag clamp
|
||||||
// half is the pure `deck_values` module. The orthogonal half — which stored struct each editor
|
// bounds. The parameter-set half is the pure `deck_values` module. The orthogonal half — which
|
||||||
// selection names — is editor_models. Value logic only: no painting, no window plumbing.
|
// stored struct each editor selection names — is editor_models. Value logic only.
|
||||||
|
|
||||||
#include "shell/instrument/reasampler_editor.h"
|
#include "shell/instrument/reasampler_editor.h"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <cmath> // isfinite (the gain's -inf label)
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <cstdio> // snprintf (deck value labels)
|
#include <cstdio> // snprintf (deck value labels)
|
||||||
#include <string>
|
#include <string>
|
||||||
@@ -241,11 +242,20 @@ std::string ReaSamplerEditor::deckValueLabel(int id) const {
|
|||||||
|
|
||||||
// The digits come from the ONE formatter; everything the editor adds around them is static
|
// The digits come from the ONE formatter; everything the editor adds around them is static
|
||||||
// chrome — a constant prefix or suffix cannot diverge from what the host shows.
|
// chrome — a constant prefix or suffix cannot diverge from what the host shows.
|
||||||
|
const double plain = deckPlainValue(id);
|
||||||
char digits[24];
|
char digits[24];
|
||||||
instrument::param::formatPlainFor(deck, deckPlainValue(id), digits, sizeof(digits));
|
instrument::param::formatPlainFor(deck, plain, digits, sizeof(digits));
|
||||||
|
const auto kind = instrument::param::unitKindFor(deck);
|
||||||
const char* caret =
|
const char* caret =
|
||||||
instrument::ui::deckParamUnit(deck) == instrument::ui::UnitCategory::Exponent ? "^" : "";
|
instrument::ui::deckParamUnit(deck) == instrument::ui::UnitCategory::Exponent ? "^" : "";
|
||||||
return caret + std::string(digits) + instrument::param::unitStringFor(deck);
|
// The gain at true silence reads "-inf", not "-infdB": there is no decibel value there.
|
||||||
|
if (kind == instrument::param::UnitKind::Decibels && !std::isfinite(plain)) {
|
||||||
|
return std::string(digits);
|
||||||
|
}
|
||||||
|
// The stage times are the one category that carries a space before its unit, and always did —
|
||||||
|
// this surface's own typography, not the host's (ParameterInfo::units is the bare string).
|
||||||
|
const char* gap = kind == instrument::param::UnitKind::Time ? " " : "";
|
||||||
|
return caret + std::string(digits) + gap + instrument::param::unitStringFor(deck);
|
||||||
}
|
}
|
||||||
|
|
||||||
EnvClampBounds ReaSamplerEditor::envClampBounds() const {
|
EnvClampBounds ReaSamplerEditor::envClampBounds() const {
|
||||||
|
|||||||
@@ -119,6 +119,10 @@ bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) {
|
|||||||
dragStartEnv_ = env;
|
dragStartEnv_ = env;
|
||||||
dragSampleFrames_ = frames;
|
dragSampleFrames_ = frames;
|
||||||
dragStartParams_ = params_;
|
dragStartParams_ = params_;
|
||||||
|
// Peer of mouseDownDeck's bracket. LATCHING with no id named, because a node or
|
||||||
|
// knot drag can move more than one exposed parameter and the grab cannot know
|
||||||
|
// which; the release and capture-lost paths close it generically.
|
||||||
|
if (processor_) processor_->beginParamGestureLatch();
|
||||||
return true; // node moves once the cursor drags
|
return true; // node moves once the cursor drags
|
||||||
}
|
}
|
||||||
return splineOverlayClick(overlay, x, y, gesture, /*addOnEmptySpace=*/false);
|
return splineOverlayClick(overlay, x, y, gesture, /*addOnEmptySpace=*/false);
|
||||||
|
|||||||
@@ -120,6 +120,11 @@ void ReaSamplerEditor::attachedToParent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void ReaSamplerEditor::removedFromParent() {
|
void ReaSamplerEditor::removedFromParent() {
|
||||||
|
// The processor outlives this view, so a bracket left open here would leave the host holding
|
||||||
|
// an edit forever and every later internal write to that parameter would emit a bare
|
||||||
|
// performEdit. The capture-lost path normally closes it; this does not rely on Windows
|
||||||
|
// delivering WM_CAPTURECHANGED before the window goes away.
|
||||||
|
if (processor_) processor_->endParamGesture();
|
||||||
if (childHwnd_) {
|
if (childHwnd_) {
|
||||||
KillTimer(childHwnd_, kSyncTimerId); // stop the poll before the window goes away
|
KillTimer(childHwnd_, kSyncTimerId); // stop the poll before the window goes away
|
||||||
DestroyWindow(childHwnd_);
|
DestroyWindow(childHwnd_);
|
||||||
|
|||||||
@@ -141,6 +141,10 @@ void ReaSamplerEditor::onSyncTimer() {
|
|||||||
// edit surface exactly as a reload would. Unconditional: it self-cancels when nothing is
|
// edit surface exactly as a reload would. Unconditional: it self-cancels when nothing is
|
||||||
// armed, so no commit site has to remember to ask for it.
|
// armed, so no commit site has to remember to ask for it.
|
||||||
processor_->flushLatencyRestart();
|
processor_->flushLatencyRestart();
|
||||||
|
// Peers of it: both deliver work the originating thread could not do where it stood — a host
|
||||||
|
// callback from inside reloadMutex_, and a model write from the audio thread.
|
||||||
|
processor_->flushGainNotify();
|
||||||
|
processor_->drainAutomationToModel();
|
||||||
|
|
||||||
// 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.
|
||||||
|
|||||||
@@ -120,6 +120,9 @@ BakeChainResult runBake(ReaSamplerProcessor& processor) {
|
|||||||
const std::optional<Tempo> tempo = Tempo::fromBpm(bridge.projectTempoBpm());
|
const std::optional<Tempo> tempo = Tempo::fromBpm(bridge.projectTempoBpm());
|
||||||
if (!tempo) return fail("the project tempo could not be read");
|
if (!tempo) return fail("the project tempo could not be read");
|
||||||
|
|
||||||
|
// Fold anything the host's automation wrote into the model FIRST: the bake renders the sound
|
||||||
|
// the user approved, and an automated value the model has not picked up yet is part of it.
|
||||||
|
processor.drainAutomationToModel();
|
||||||
const InstrumentParams dialed = processor.instrumentParams();
|
const InstrumentParams dialed = processor.instrumentParams();
|
||||||
const int rootNote = dialed.rootOverride ? *dialed.rootOverride : source->rootNote;
|
const int rootNote = dialed.rootOverride ? *dialed.rootOverride : source->rootNote;
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,14 @@
|
|||||||
// instrument_params.cpp — the VST3 adapter over core/instrument/param: the Parameter subclass
|
// instrument_params.cpp — the VST3 adapter over core/instrument/param: the Parameter subclass
|
||||||
// whose toPlain/toNormalized ARE the taper, the one construction of the unit and parameter
|
// whose toPlain/toNormalized ARE the taper, the construction of the unit and parameter lists,
|
||||||
// lists, and the model projection both directions. It DECIDES nothing — the pure module owns
|
// the model projection both directions, and both delivery channels (the controller's write and
|
||||||
// the frozen table, the laws and the formatter.
|
// the audio thread's queue drain). It DECIDES nothing — the pure module owns the frozen table,
|
||||||
//
|
// the laws and the formatter.
|
||||||
// The blob stays authoritative. A parameter is a THIRD SURFACE onto InstrumentParams/PlaySeconds
|
|
||||||
// — a peer of the deck knob and the overlay node, never a second copy of the value. getState
|
|
||||||
// serializes the model; the controller's own value list is a cache written FROM the model and
|
|
||||||
// never read as truth.
|
|
||||||
|
|
||||||
#include "shell/instrument/reasampler_processor.h"
|
#include "shell/instrument/reasampler_processor.h"
|
||||||
|
|
||||||
#include "base/source/fstring.h"
|
#include "base/source/fstring.h"
|
||||||
#include "pluginterfaces/base/ustring.h"
|
#include "pluginterfaces/base/ustring.h"
|
||||||
|
#include "pluginterfaces/vst/ivstparameterchanges.h" // IParameterChanges / IParamValueQueue
|
||||||
|
|
||||||
#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"
|
||||||
@@ -123,7 +120,7 @@ tresult PLUGIN_API ReaSamplerProcessor::setParamNormalized(ParamID tag, ParamVal
|
|||||||
setMasterGainLinear(instrument::engine::masterGainLinearFromNorm(value));
|
setMasterGainLinear(instrument::engine::masterGainLinearFromNorm(value));
|
||||||
} else {
|
} else {
|
||||||
InstrumentParams params = instrumentParams();
|
InstrumentParams params = instrumentParams();
|
||||||
instrument::ui::setDeckParam(row->deck, params.play, value, /*segment=*/0);
|
writeDeckParamToModel(params, row->deck, value);
|
||||||
setInstrumentParams(params);
|
setInstrumentParams(params);
|
||||||
publishLiveParams();
|
publishLiveParams();
|
||||||
}
|
}
|
||||||
@@ -134,11 +131,25 @@ tresult PLUGIN_API ReaSamplerProcessor::setParamNormalized(ParamID tag, ParamVal
|
|||||||
tag, modelParamNormalized(instrumentParams(), row->deck));
|
tag, modelParamNormalized(instrumentParams(), row->deck));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ReaSamplerProcessor::writeDeckParamToModel(InstrumentParams& params, DeckParam deck,
|
||||||
|
double normalized) {
|
||||||
|
// The two homes a control's value can have, and the ONE place the host path knows the
|
||||||
|
// difference — the editor draws the same split at applyParamControl. param::valueHomeFor is
|
||||||
|
// the predicate; a promotion whose control has neither home fails param_units' own test
|
||||||
|
// rather than no-oping silently here.
|
||||||
|
if (deck == DeckParam::kKeyTrack) {
|
||||||
|
params.keyTrack = instrument::ui::keyTrackFromNorm(normalized);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
instrument::ui::setDeckParam(deck, params.play, normalized, /*segment=*/0);
|
||||||
|
}
|
||||||
|
|
||||||
double ReaSamplerProcessor::modelParamNormalized(const InstrumentParams& params,
|
double ReaSamplerProcessor::modelParamNormalized(const InstrumentParams& params,
|
||||||
DeckParam deck) const {
|
DeckParam deck) const {
|
||||||
if (deck == DeckParam::kMasterGain) {
|
if (deck == DeckParam::kMasterGain) {
|
||||||
return instrument::engine::masterGainNormFromLinear(masterGainLinear());
|
return instrument::engine::masterGainNormFromLinear(masterGainLinear());
|
||||||
}
|
}
|
||||||
|
if (deck == DeckParam::kKeyTrack) return instrument::ui::keyTrackNormFrom(params.keyTrack);
|
||||||
return instrument::ui::deckParamNorm(deck, params.play);
|
return instrument::ui::deckParamNorm(deck, params.play);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,43 +163,145 @@ void ReaSamplerProcessor::syncParamsFromModel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void ReaSamplerProcessor::notifyParamsFromModel(const InstrumentParams& before,
|
void ReaSamplerProcessor::notifyParamsFromModel(const double* beforeNorms,
|
||||||
const InstrumentParams& after) {
|
const InstrumentParams& after) {
|
||||||
if (paramNotifySuppressed_) return;
|
if (paramNotifySuppressed_) return;
|
||||||
|
// The bake's reset moves ~40 values at once. Grouping them tells the host they are ONE act,
|
||||||
|
// which is what an undo stack and an automation lane both want; the SDK provides exactly
|
||||||
|
// this for exactly this case (ivsteditcontroller.h, IComponentHandler2).
|
||||||
|
const bool group = componentHandler2 && !gestureLatching_;
|
||||||
|
if (group) componentHandler2->startGroupEdit();
|
||||||
for (const param::ParamRow& row : param::exposedParams()) {
|
for (const param::ParamRow& row : param::exposedParams()) {
|
||||||
if (row.deck == DeckParam::kMasterGain) continue; // its own funnel notifies it
|
if (row.deck == DeckParam::kMasterGain) continue; // its own funnel notifies it
|
||||||
const double now = modelParamNormalized(after, row.deck);
|
const double now = modelParamNormalized(after, row.deck);
|
||||||
if (now == modelParamNormalized(before, row.deck)) continue;
|
if (now == beforeNorms[static_cast<std::size_t>(row.deck)]) continue;
|
||||||
notifyParamChanged(row.id, now);
|
notifyParamChanged(row.id, now);
|
||||||
}
|
}
|
||||||
|
if (group) componentHandler2->finishGroupEdit();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ReaSamplerProcessor::gestureIsOpen(param::ParamId id) const {
|
||||||
|
for (std::size_t i = 0; i < openGestureCount_; ++i) {
|
||||||
|
if (openGestureIds_[i] == id) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
void ReaSamplerProcessor::notifyParamChanged(param::ParamId id, double normalized) {
|
void ReaSamplerProcessor::notifyParamChanged(param::ParamId id, double normalized) {
|
||||||
EditControllerEx1::setParamNormalized(id, normalized);
|
EditControllerEx1::setParamNormalized(id, normalized);
|
||||||
if (!componentHandler) return;
|
if (!componentHandler) return;
|
||||||
// A drag holds its own begin/end across the whole gesture so a host in touch or latch mode
|
if (gestureIsOpen(id)) {
|
||||||
// sees one continuous edit; every other writer — a reset, an envelope-node drag, the bake's
|
|
||||||
// reset — emits a degenerate one-point gesture, which is what makes the host DISPLAY follow
|
|
||||||
// it instead of re-imposing the pre-write value on the next touch.
|
|
||||||
const bool inGesture = openGestureId_ == id;
|
|
||||||
if (!inGesture) beginEdit(id);
|
|
||||||
performEdit(id, normalized);
|
performEdit(id, normalized);
|
||||||
if (!inGesture) endEdit(id);
|
return;
|
||||||
}
|
}
|
||||||
|
if (gestureLatching_ && openGestureCount_ < kMaxOpenGestures) {
|
||||||
void ReaSamplerProcessor::beginParamGesture(DeckParam deck) {
|
// First move this drag has made on this parameter: open its bracket and hold it, so the
|
||||||
const param::ParamId id = param::paramIdFor(deck);
|
// whole drag is one edit rather than a run of one-point ones.
|
||||||
if (id == 0 || !param::isExposed(deck)) return;
|
openGestureIds_[openGestureCount_++] = id;
|
||||||
endParamGesture(); // a grab while one is open cannot leave the previous unclosed
|
|
||||||
openGestureId_ = id;
|
|
||||||
beginEdit(id);
|
beginEdit(id);
|
||||||
}
|
performEdit(id, normalized);
|
||||||
|
return;
|
||||||
void ReaSamplerProcessor::endParamGesture() {
|
}
|
||||||
if (openGestureId_ == 0) return;
|
// Every non-drag writer — a reset, the bake's reset — emits a degenerate one-point gesture,
|
||||||
const param::ParamId id = openGestureId_;
|
// which is what makes the host DISPLAY follow it instead of re-imposing the pre-write value
|
||||||
openGestureId_ = 0; // cleared FIRST: endEdit can re-enter through a host's own callback
|
// on the next touch.
|
||||||
|
beginEdit(id);
|
||||||
|
performEdit(id, normalized);
|
||||||
endEdit(id);
|
endEdit(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ReaSamplerProcessor::beginParamGestureLatch() {
|
||||||
|
endParamGesture(); // a grab while one is open cannot leave the previous unclosed
|
||||||
|
gestureLatching_ = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ReaSamplerProcessor::beginParamGesture(DeckParam deck) {
|
||||||
|
beginParamGestureLatch();
|
||||||
|
const param::ParamId id = param::paramIdFor(deck);
|
||||||
|
if (id == 0 || !param::isExposed(deck)) return;
|
||||||
|
openGestureIds_[openGestureCount_++] = id;
|
||||||
|
beginEdit(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ReaSamplerProcessor::drainInputParameterChanges(IParameterChanges* changes) {
|
||||||
|
if (!changes) return false;
|
||||||
|
// RT-SAFE, and the one non-obvious part of that: exposedRowFor walks exposedParams(), whose
|
||||||
|
// backing vector is a function-local static built on FIRST CALL. buildParameterList() calls
|
||||||
|
// it from initialize(), which the SDK guarantees precedes any process() — so the allocation
|
||||||
|
// has already happened by the time the audio thread gets here.
|
||||||
|
bool landed = false;
|
||||||
|
const int32 queues = changes->getParameterCount();
|
||||||
|
for (int32 q = 0; q < queues; ++q) {
|
||||||
|
IParamValueQueue* queue = changes->getParameterData(q);
|
||||||
|
if (!queue) continue;
|
||||||
|
const int32 points = queue->getPointCount();
|
||||||
|
if (points <= 0) continue;
|
||||||
|
// The LAST point of the queue wins for the block. Applying every point at its sample
|
||||||
|
// offset would put a "did anything change" question on the per-voice-per-sample path,
|
||||||
|
// which the phase-wide guardrail forbids.
|
||||||
|
int32 offset = 0;
|
||||||
|
ParamValue value = 0.0;
|
||||||
|
if (queue->getPoint(points - 1, offset, value) != kResultTrue) continue;
|
||||||
|
const param::ParamRow* row = param::exposedRowFor(queue->getParameterId());
|
||||||
|
if (!row) continue;
|
||||||
|
landed = true;
|
||||||
|
const auto slot = static_cast<std::size_t>(row->deck);
|
||||||
|
automationNorm_[slot] = value;
|
||||||
|
automationHeld_[slot] = true;
|
||||||
|
// Master gain reaches the audio beside the block rather than through it, so its
|
||||||
|
// automation write is the same one relaxed store the knob makes.
|
||||||
|
if (row->deck == DeckParam::kMasterGain) {
|
||||||
|
masterGain_.store(
|
||||||
|
static_cast<float>(instrument::engine::masterGainLinearFromNorm(value)),
|
||||||
|
std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
// Publish to the UI thread, which folds it back into the model — the blob stays
|
||||||
|
// authoritative, so a value that never came back would be lost on save.
|
||||||
|
automationPublished_[slot].store(value, std::memory_order_relaxed);
|
||||||
|
automationPending_[slot].store(true, std::memory_order_release);
|
||||||
|
}
|
||||||
|
if (landed) automationAny_.store(true, std::memory_order_release);
|
||||||
|
return landed;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ReaSamplerProcessor::drainAutomationToModel() {
|
||||||
|
if (!automationAny_.exchange(false, std::memory_order_acquire)) return;
|
||||||
|
InstrumentParams params = instrumentParams();
|
||||||
|
bool moved = false;
|
||||||
|
for (const param::ParamRow& row : param::exposedParams()) {
|
||||||
|
const auto slot = static_cast<std::size_t>(row.deck);
|
||||||
|
if (!automationPending_[slot].exchange(false, std::memory_order_acquire)) continue;
|
||||||
|
const double value = automationPublished_[slot].load(std::memory_order_relaxed);
|
||||||
|
// Master gain's model IS the atomic the audio thread already wrote; there is nothing to
|
||||||
|
// fold, only the controller cache to refresh below.
|
||||||
|
if (row.deck != DeckParam::kMasterGain) {
|
||||||
|
writeDeckParamToModel(params, row.deck, value);
|
||||||
|
moved = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Suppressed for the whole fold: these values CAME from the host, and echoing them back
|
||||||
|
// through performEdit would let a lane in write mode re-record its own playback. The
|
||||||
|
// controller cache is still refreshed, so the host's display and the editor follow.
|
||||||
|
const bool wasSuppressed = paramNotifySuppressed_;
|
||||||
|
paramNotifySuppressed_ = true;
|
||||||
|
if (moved) {
|
||||||
|
setInstrumentParams(params);
|
||||||
|
publishLiveParams();
|
||||||
|
}
|
||||||
|
syncParamsFromModel();
|
||||||
|
paramNotifySuppressed_ = wasSuppressed;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ReaSamplerProcessor::endParamGesture() {
|
||||||
|
gestureLatching_ = false;
|
||||||
|
if (openGestureCount_ == 0) return;
|
||||||
|
// Latched into a local and the state cleared FIRST: endEdit can re-enter through a host's
|
||||||
|
// own callback, and must not find a bracket this call is in the middle of closing.
|
||||||
|
param::ParamId closing[kMaxOpenGestures];
|
||||||
|
const std::size_t count = openGestureCount_;
|
||||||
|
for (std::size_t i = 0; i < count; ++i) closing[i] = openGestureIds_[i];
|
||||||
|
openGestureCount_ = 0;
|
||||||
|
for (std::size_t i = 0; i < count; ++i) endEdit(closing[i]);
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace reasampler::vst
|
} // namespace reasampler::vst
|
||||||
|
|||||||
@@ -146,16 +146,18 @@ std::string ReaSamplerProcessor::reloadInstrument() {
|
|||||||
buildFromRef(*sel, params, projectDir, mode)) {
|
buildFromRef(*sel, params, projectDir, mode)) {
|
||||||
sample = std::move(*decoded);
|
sample = std::move(*decoded);
|
||||||
havePlayable = true;
|
havePlayable = true;
|
||||||
// Point the built snapshot at the instance's ONE live block and seed it from
|
// Point the built snapshot at the instance's ONE engine-facing block and seed the
|
||||||
// the very PlayParams the voices latch, so an untouched knob folds to the same
|
// MODEL block from the very PlayParams the voices latch, so an untouched knob folds
|
||||||
// frames the build resolved and a note-on with a live block sounds identical
|
// to the same frames the build resolved and a note-on with a live block sounds
|
||||||
// to one without.
|
// identical to one without. process() merges the seed into automationLive_ at the
|
||||||
sample.live = &liveParams_;
|
// top of the first block after this, which is where the swap below becomes visible
|
||||||
|
// too — so the new snapshot's first note reads it.
|
||||||
|
sample.live = &automationLive_;
|
||||||
{
|
{
|
||||||
// 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));
|
liveParams_.publish(instrument::engine::foldLive(sample.play, sample.keyTrack));
|
||||||
}
|
}
|
||||||
builtSampleRate_.store(sample.sampleRate, std::memory_order_relaxed);
|
builtSampleRate_.store(sample.sampleRate, std::memory_order_relaxed);
|
||||||
resolvedId = selId; // the concrete pick that resolved
|
resolvedId = selId; // the concrete pick that resolved
|
||||||
@@ -226,6 +228,8 @@ void ReaSamplerProcessor::adoptBakedCapture(const SampleRefEntry& entry,
|
|||||||
// bake chain only ever runs from that tick, so the arm would be drained on the next one
|
// bake chain only ever runs from that tick, so the arm would be drained on the next one
|
||||||
// anyway. At the tail for the same reason setState's is (see there).
|
// anyway. At the tail for the same reason setState's is (see there).
|
||||||
flushLatencyRestart();
|
flushLatencyRestart();
|
||||||
|
// The reset's gain notification, armed under reloadMutex_ inside that reload.
|
||||||
|
flushGainNotify();
|
||||||
}
|
}
|
||||||
|
|
||||||
void ReaSamplerProcessor::publishUsage(const SampleRefs& refs,
|
void ReaSamplerProcessor::publishUsage(const SampleRefs& refs,
|
||||||
@@ -289,8 +293,16 @@ void ReaSamplerProcessor::publishBuiltLocked(std::unique_ptr<LoadedInstrument> b
|
|||||||
// gain sitting above every snapshot, the same shape as ONE BLOCK, ONE RATE (see
|
// gain sitting above every snapshot, the same shape as ONE BLOCK, ONE RATE (see
|
||||||
// builtSampleRate_).
|
// builtSampleRate_).
|
||||||
if (gainAtNextPublish_) {
|
if (gainAtNextPublish_) {
|
||||||
setMasterGainLinear(*gainAtNextPublish_);
|
// The MIRROR is inline (that is the sound this publish belongs to); the host
|
||||||
|
// notification is ARMED and delivered after reloadMutex_ is released. performEdit
|
||||||
|
// reaches the host handler, a host may re-enter this object synchronously from it, and
|
||||||
|
// setActive(false) takes this same non-recursive mutex — the identical hazard the
|
||||||
|
// latency restart is deferred for.
|
||||||
|
const bool moved = publishMasterGainLinear(*gainAtNextPublish_);
|
||||||
gainAtNextPublish_.reset();
|
gainAtNextPublish_.reset();
|
||||||
|
// Armed only on a real change, so a reset that lands on the gain already set writes
|
||||||
|
// nothing into a host's automation lane — the same compare setMasterGainLinear makes.
|
||||||
|
if (moved) gainNotifyPending_.store(true, std::memory_order_release);
|
||||||
}
|
}
|
||||||
LoadedInstrument* evicted = draining_.exchange(prev);
|
LoadedInstrument* evicted = draining_.exchange(prev);
|
||||||
if (evicted) graveyard_.push_back(std::unique_ptr<LoadedInstrument>(evicted));
|
if (evicted) graveyard_.push_back(std::unique_ptr<LoadedInstrument>(evicted));
|
||||||
|
|||||||
@@ -91,20 +91,26 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) {
|
|||||||
legacyLiftConcluded_.store(false, std::memory_order_relaxed);
|
legacyLiftConcluded_.store(false, std::memory_order_relaxed);
|
||||||
reloadInstrument();
|
reloadInstrument();
|
||||||
paramNotifySuppressed_ = false;
|
paramNotifySuppressed_ = false;
|
||||||
// Every exposed parameter now reads the blob's value. Ordering against the host's first
|
// Every exposed parameter now reads the blob's value. Its ordering against the host's first
|
||||||
// parameter block is irrelevant BY CONSTRUCTION rather than by assumption: there is one
|
// parameter block does not need to be known: an automation point held by the audio thread is
|
||||||
// model and one funnel per control, so whichever of the two writes last simply wins, and
|
// re-applied over every merge, so a lane outranks this restore whichever way round the two
|
||||||
// the host's display follows the model either way.
|
// arrive. That is VST3's own rule — a written lane outranks anything the plug-in sets — not
|
||||||
|
// a race we lost.
|
||||||
syncParamsFromModel();
|
syncParamsFromModel();
|
||||||
// This caller has no editor to flush for it. At the TAIL on purpose: a host that services the
|
// This caller has no editor to flush for it. At the TAIL on purpose: a host that services the
|
||||||
// restart synchronously deactivates/reactivates, and our setActive(true) resumes or reloads
|
// restart synchronously deactivates/reactivates, and our setActive(true) resumes or reloads
|
||||||
// against the refs above, which are only fully restored once this function has run to here.
|
// against the refs above, which are only fully restored once this function has run to here.
|
||||||
flushLatencyRestart();
|
flushLatencyRestart();
|
||||||
|
flushGainNotify();
|
||||||
return kResultOk;
|
return kResultOk;
|
||||||
}
|
}
|
||||||
|
|
||||||
tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) {
|
tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) {
|
||||||
if (!state) return kResultFalse;
|
if (!state) return kResultFalse;
|
||||||
|
// Before the snapshot, not after: the blob is authoritative, so anything the host's
|
||||||
|
// automation wrote must be in the model by the time it is serialised. This is the one drain
|
||||||
|
// site that is not an optimisation — a save with no editor open still has to see it.
|
||||||
|
drainAutomationToModel();
|
||||||
// Persists the full instance state — never written to the "reasampler" bank ext-state.
|
// Persists the full instance state — never written to the "reasampler" bank ext-state.
|
||||||
// No pick serializes to {"", default params}, restoring as silence (never auto-playing
|
// No pick serializes to {"", default params}, restoring as silence (never auto-playing
|
||||||
// sample #1).
|
// sample #1).
|
||||||
@@ -163,10 +169,15 @@ InstrumentParams ReaSamplerProcessor::instrumentParams() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void ReaSamplerProcessor::setInstrumentParams(const InstrumentParams& params) {
|
void ReaSamplerProcessor::setInstrumentParams(const InstrumentParams& params) {
|
||||||
InstrumentParams before;
|
// 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
|
||||||
|
// 44 doubles is the cost, and the diff is what the notification actually needs.
|
||||||
|
double before[kDeckParamSlots];
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(paramsMutex_);
|
std::lock_guard<std::mutex> lock(paramsMutex_);
|
||||||
before = params_;
|
for (const instrument::param::ParamRow& row : instrument::param::exposedParams()) {
|
||||||
|
before[static_cast<std::size_t>(row.deck)] = modelParamNormalized(params_, row.deck);
|
||||||
|
}
|
||||||
params_ = params;
|
params_ = params;
|
||||||
}
|
}
|
||||||
// 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 —
|
||||||
@@ -250,8 +261,9 @@ void ReaSamplerProcessor::clearMasterBusClip() {
|
|||||||
void ReaSamplerProcessor::publishLiveParams() {
|
void ReaSamplerProcessor::publishLiveParams() {
|
||||||
const int rate = builtSampleRate_.load(std::memory_order_relaxed);
|
const int rate = builtSampleRate_.load(std::memory_order_relaxed);
|
||||||
if (rate <= 0) return;
|
if (rate <= 0) return;
|
||||||
|
const InstrumentParams params = instrumentParams();
|
||||||
const instrument::engine::LiveValues block =
|
const instrument::engine::LiveValues block =
|
||||||
instrument::engine::foldLive(resolvePlay(instrumentParams().play, rate));
|
instrument::engine::foldLive(resolvePlay(params.play, rate), params.keyTrack);
|
||||||
// livePublishMutex_ enforces the seqlock's single-writer contract (live_params.h) against
|
// livePublishMutex_ enforces the seqlock's single-writer contract (live_params.h) against
|
||||||
// reloadInstrument's publish — held for the publish call only, not the fold above.
|
// reloadInstrument's publish — held for the publish call only, not the fold above.
|
||||||
std::lock_guard<std::mutex> lock(livePublishMutex_);
|
std::lock_guard<std::mutex> lock(livePublishMutex_);
|
||||||
@@ -329,20 +341,32 @@ void ReaSamplerProcessor::setMonoTrigger(MonoTrigger trigger) {
|
|||||||
rebuildVoiceEngine();
|
rebuildVoiceEngine();
|
||||||
}
|
}
|
||||||
|
|
||||||
void ReaSamplerProcessor::setMasterGainLinear(double linear) {
|
bool ReaSamplerProcessor::publishMasterGainLinear(double linear) {
|
||||||
// Clamp to the master_gain taper (0 = silence, cap = +24 dB). One relaxed atomic
|
// Clamp to the master_gain taper (0 = silence, cap = +24 dB). One relaxed atomic
|
||||||
// store — no rebuild, no lock (a post-sum trim is not a keymap fact).
|
// store — no rebuild, no lock (a post-sum trim is not a keymap fact).
|
||||||
if (!(linear >= 0.0)) linear = 0.0; // also catches NaN
|
if (!(linear >= 0.0)) linear = 0.0; // also catches NaN
|
||||||
const double maxLin = masterGainMaxLinear();
|
const double maxLin = masterGainMaxLinear();
|
||||||
if (linear > maxLin) linear = maxLin;
|
if (linear > maxLin) linear = maxLin;
|
||||||
const float value = static_cast<float>(linear);
|
const float value = static_cast<float>(linear);
|
||||||
const float previous = masterGain_.exchange(value, std::memory_order_relaxed);
|
return masterGain_.exchange(value, std::memory_order_relaxed) != value;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ReaSamplerProcessor::setMasterGainLinear(double linear) {
|
||||||
|
const bool moved = publishMasterGainLinear(linear);
|
||||||
// Gain's own notification funnel — it is the one exposed control that does not ride the
|
// Gain's own notification funnel — it is the one exposed control that does not ride the
|
||||||
// parameter set, so setInstrumentParams' diff cannot see it. Compared for a real change so a
|
// parameter set, so setInstrumentParams' diff cannot see it. Compared for a real change so a
|
||||||
// reload's republish of an unmoved gain writes nothing into a host's automation lane.
|
// reload's republish of an unmoved gain writes nothing into a host's automation lane.
|
||||||
if (paramNotifySuppressed_ || previous == value) return;
|
if (paramNotifySuppressed_ || !moved) return;
|
||||||
notifyParamChanged(instrument::param::kParamMasterGain,
|
notifyParamChanged(instrument::param::kParamMasterGain,
|
||||||
instrument::engine::masterGainNormFromLinear(linear));
|
instrument::engine::masterGainNormFromLinear(masterGainLinear()));
|
||||||
|
}
|
||||||
|
|
||||||
|
void ReaSamplerProcessor::flushGainNotify() {
|
||||||
|
if (!componentHandler) return; // an arm raised before the handler connected waits
|
||||||
|
if (!gainNotifyPending_.exchange(false, std::memory_order_acquire)) return;
|
||||||
|
if (paramNotifySuppressed_) return;
|
||||||
|
notifyParamChanged(instrument::param::kParamMasterGain,
|
||||||
|
instrument::engine::masterGainNormFromLinear(masterGainLinear()));
|
||||||
}
|
}
|
||||||
|
|
||||||
void ReaSamplerProcessor::previewNoteOn(int note) {
|
void ReaSamplerProcessor::previewNoteOn(int note) {
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
#include "pluginterfaces/vst/ivstmidicontrollers.h" // kCtrlAllNotesOff / kCtrlAllSoundsOff (panic)
|
#include "pluginterfaces/vst/ivstmidicontrollers.h" // kCtrlAllNotesOff / kCtrlAllSoundsOff (panic)
|
||||||
#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/param/param_live.h" // the RT-safe patch of one control into the block
|
||||||
#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)
|
||||||
|
|
||||||
@@ -200,6 +202,33 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
|
|||||||
(drain && drain->fullyIdle()) ? drain->installedAt : 0,
|
(drain && drain->fullyIdle()) ? drain->installedAt : 0,
|
||||||
std::memory_order_relaxed);
|
std::memory_order_relaxed);
|
||||||
|
|
||||||
|
// Host automation, merged into the engine-facing block BEFORE the note marshalling below, so
|
||||||
|
// a note-on in this block latches this block's values. See automationLive_ (header) for why
|
||||||
|
// the merge is here rather than in the model's own publisher.
|
||||||
|
{
|
||||||
|
const bool dirty = drainInputParameterChanges(data.inputParameterChanges);
|
||||||
|
const std::uint32_t modelGen = liveParams_.generation();
|
||||||
|
if (dirty || modelGen != seenModelGeneration_) {
|
||||||
|
// Declared INSIDE the branch: LiveValues carries default member initializers, so a
|
||||||
|
// block where nothing moved must not pay to construct one.
|
||||||
|
instrument::engine::LiveValues merged;
|
||||||
|
// Re-read the model's fold and re-apply every held automation value over it: without
|
||||||
|
// the re-apply, any knob move would revert an automated parameter until its lane's
|
||||||
|
// next point.
|
||||||
|
if (liveParams_.read(merged) != 0) {
|
||||||
|
seenModelGeneration_ = modelGen;
|
||||||
|
const int builtRate = builtSampleRate_.load(std::memory_order_relaxed);
|
||||||
|
for (std::size_t i = 0; i < kDeckParamSlots; ++i) {
|
||||||
|
if (!automationHeld_[i]) continue;
|
||||||
|
instrument::param::applyLiveParam(
|
||||||
|
merged, static_cast<instrument::ui::DeckParam>(i), automationNorm_[i],
|
||||||
|
builtRate);
|
||||||
|
}
|
||||||
|
automationLive_.publish(merged);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Marshal MIDI note-on/off at block granularity (no per-event sample-offset split;
|
// Marshal MIDI note-on/off at block granularity (no per-event sample-offset split;
|
||||||
// sample-accurate scheduling is a later tier). Note-offs also route to the drain
|
// sample-accurate scheduling is a later tier). Note-offs also route to the drain
|
||||||
// engine so a note held across a reload releases its old-snapshot voice too.
|
// engine so a note held across a reload releases its old-snapshot voice too.
|
||||||
|
|||||||
@@ -125,17 +125,12 @@ public:
|
|||||||
// Hands the host our LICE IPlugView editor.
|
// Hands the host our LICE IPlugView editor.
|
||||||
Steinberg::IPlugView* PLUGIN_API createView(Steinberg::FIDString name) override;
|
Steinberg::IPlugView* PLUGIN_API createView(Steinberg::FIDString name) override;
|
||||||
|
|
||||||
// A host write of one exposed parameter. Applies it to THE model through that control's
|
// The CONTROLLER-side write — a host GUI gesture on the generic panel, and whatever a host
|
||||||
// existing commit tier — live publish, or the master-gain atomic — and never through a
|
// mirrors here for display. Applies it to THE model through that control's existing commit
|
||||||
// fourth route. Nothing reachable from here touches reloadInstrument or rebuildVoiceEngine,
|
// tier and never through a fourth route. Nothing reachable from here touches reloadInstrument
|
||||||
// which is structural rather than careful: every reload- and rebuild-tier control is omitted
|
// or rebuildVoiceEngine, which is structural rather than careful: every reload- and
|
||||||
// from the parameter list, so no id maps to one.
|
// rebuild-tier control is omitted from the parameter list, so no id maps to one.
|
||||||
//
|
// NOT the automation channel; this directory's CLAUDE.md owns which channel is which.
|
||||||
// Parameter values reach the audio thread through the SAME block the editor's knobs publish
|
|
||||||
// into, which the engine observes ONCE per render() — i.e. at BLOCK BOUNDARIES, last write
|
|
||||||
// wins for that block. Sample-accurate application would put a per-sample "did anything
|
|
||||||
// change" question on the per-voice-per-sample path, which the phase-wide guardrail forbids.
|
|
||||||
// process() reads no parameter queue and is unchanged by the parameter surface.
|
|
||||||
Steinberg::tresult PLUGIN_API setParamNormalized(
|
Steinberg::tresult PLUGIN_API setParamNormalized(
|
||||||
Steinberg::Vst::ParamID tag, Steinberg::Vst::ParamValue value) override;
|
Steinberg::Vst::ParamID tag, Steinberg::Vst::ParamValue value) override;
|
||||||
|
|
||||||
@@ -144,10 +139,20 @@ public:
|
|||||||
// through IComponentHandler. UI/main thread.
|
// through IComponentHandler. UI/main thread.
|
||||||
void syncParamsFromModel();
|
void syncParamsFromModel();
|
||||||
|
|
||||||
// A knob drag's host-edit bracket, so a host in touch or latch mode records ONE continuous
|
// Folds what the audio thread took from the host's parameter queue back into the model and
|
||||||
// edit rather than a burst of one-point gestures. Idempotent: a grab while one is open
|
// the controller cache, suppressing the host notification (those values came FROM it).
|
||||||
// closes it first, and endParamGesture with none open does nothing. UI thread only.
|
// 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.
|
||||||
|
void drainAutomationToModel();
|
||||||
|
|
||||||
|
// A drag's host-edit bracket, so a host in touch or latch mode records ONE continuous edit
|
||||||
|
// per parameter rather than a burst of one-point ones. Every parameter the drag notifies
|
||||||
|
// opens its bracket on first touch and holds it until endParamGesture — LATCHING rather than
|
||||||
|
// declared up front, because an envelope-node drag moves a set the grab cannot name. The
|
||||||
|
// deck-knob form additionally opens its own id at once, which is the id the host sees a touch
|
||||||
|
// on even if the drag produces no move. Idempotent. UI thread only.
|
||||||
void beginParamGesture(instrument::ui::DeckParam deck);
|
void beginParamGesture(instrument::ui::DeckParam deck);
|
||||||
|
void beginParamGestureLatch();
|
||||||
void endParamGesture();
|
void endParamGesture();
|
||||||
|
|
||||||
// Additionally exposes REAPER's IReaperUIEmbedInterface (queried by REAPER to drive the
|
// Additionally exposes REAPER's IReaperUIEmbedInterface (queried by REAPER to drive the
|
||||||
@@ -264,6 +269,9 @@ public:
|
|||||||
return static_cast<double>(masterGain_.load(std::memory_order_relaxed));
|
return static_cast<double>(masterGain_.load(std::memory_order_relaxed));
|
||||||
}
|
}
|
||||||
void setMasterGainLinear(double linear); // clamped to [0, masterGainMaxLinear()]
|
void setMasterGainLinear(double linear); // clamped to [0, masterGainMaxLinear()]
|
||||||
|
// The mirror alone, with no host notification: for the one writer that runs under
|
||||||
|
// reloadMutex_ and must arm rather than emit. True when the value actually moved.
|
||||||
|
bool publishMasterGainLinear(double linear);
|
||||||
|
|
||||||
// The master-bus limiter's single enable (persisted in the parameter set). UI thread only:
|
// The master-bus limiter's single enable (persisted in the parameter set). UI thread only:
|
||||||
// a thin wrapper over setInstrumentParams, the one funnel that mirrors the flag onto the
|
// a thin wrapper over setInstrumentParams, the one funnel that mirrors the flag onto the
|
||||||
@@ -284,6 +292,11 @@ public:
|
|||||||
// same tick, so its arm would drain on the next one regardless).
|
// same tick, so its arm would drain on the next one regardless).
|
||||||
void flushLatencyRestart();
|
void flushLatencyRestart();
|
||||||
|
|
||||||
|
// Delivers the master-gain host notification a reload deferred (the bake's reset gain lands
|
||||||
|
// under reloadMutex_, and performEdit may re-enter this object). Drained beside the latency
|
||||||
|
// restart, from the same sites and for the same reason.
|
||||||
|
void flushGainNotify();
|
||||||
|
|
||||||
// Fires a one-shot preview note-on/off through the live VoiceEngine — the same
|
// Fires a one-shot preview note-on/off through the live VoiceEngine — the same
|
||||||
// noteOn/noteOff host MIDI uses, so a preview is a real voice (counts against voice
|
// noteOn/noteOff host MIDI uses, so a preview is a real voice (counts against voice
|
||||||
// count, can steal/be stolen, respects Poly/Mono + Retrigger/Legato). Off the audio
|
// count, can steal/be stolen, respects Poly/Mono + Retrigger/Legato). Off the audio
|
||||||
@@ -306,18 +319,33 @@ private:
|
|||||||
// initialize().
|
// initialize().
|
||||||
void buildParameterList();
|
void buildParameterList();
|
||||||
|
|
||||||
// The normalized value a control reads at, from the model — the projection §6.1 calls a
|
// THE automation read, on the audio thread, at the BLOCK BOUNDARY: the last point of each
|
||||||
// third surface. Master gain reads the processor's own atomic; everything else reads the
|
// queue wins. RT-safe — relaxed atomic stores only. Sample-accurate application would put a
|
||||||
// parameter set through the deck's binding.
|
// per-sample "did anything change" question on the per-voice-per-sample path, which the
|
||||||
|
// phase-wide guardrail forbids. True when at least one point landed, which is what makes the
|
||||||
|
// merge below it conditional.
|
||||||
|
bool drainInputParameterChanges(Steinberg::Vst::IParameterChanges* changes);
|
||||||
|
|
||||||
|
// The normalized value a control reads at, from the model — a projection of it, never a
|
||||||
|
// cached shadow. Master gain reads the processor's own atomic, pitch key-track the scalar
|
||||||
|
// beside the play bundle; everything else reads the parameter set through the deck's binding.
|
||||||
double modelParamNormalized(const InstrumentParams& params,
|
double modelParamNormalized(const InstrumentParams& params,
|
||||||
instrument::ui::DeckParam deck) const;
|
instrument::ui::DeckParam deck) const;
|
||||||
|
|
||||||
// Notifies the host of every exposed control whose value differs between the two parameter
|
// The write peer of that read, and the ONE host-side write of a control's value: both the
|
||||||
// sets. Called from setInstrumentParams — the ONE funnel every writer already goes through —
|
// controller's setParamNormalized and the audio thread's automation fold go through it, so
|
||||||
// so no internal write can leave the host displaying, and on next touch re-imposing, a
|
// neither can miss a control whose value lives outside the parameter set.
|
||||||
// superseded value. The bake's reset is the first non-gesture writer this covers.
|
static void writeDeckParamToModel(InstrumentParams& params, instrument::ui::DeckParam deck,
|
||||||
void notifyParamsFromModel(const InstrumentParams& before, const InstrumentParams& after);
|
double normalized);
|
||||||
|
|
||||||
|
// Notifies the host of every exposed control whose normalized value moved. `beforeNorms` is
|
||||||
|
// indexed by DeckParam ordinal. Called from setInstrumentParams — the ONE funnel every writer
|
||||||
|
// already goes through — so no internal write can leave the host displaying, and on next
|
||||||
|
// touch re-imposing, a superseded value. The bake's reset is the first non-gesture writer
|
||||||
|
// this covers, and the reason the emission is grouped.
|
||||||
|
void notifyParamsFromModel(const double* beforeNorms, const InstrumentParams& after);
|
||||||
void notifyParamChanged(instrument::param::ParamId id, double normalized);
|
void notifyParamChanged(instrument::param::ParamId id, double normalized);
|
||||||
|
bool gestureIsOpen(instrument::param::ParamId id) const;
|
||||||
|
|
||||||
// If process() published that the drain instrument is fully idle, move it into the
|
// If process() published that the drain instrument is fully idle, move it into the
|
||||||
// graveyard and prune — so an edited-away snapshot stops costing memory as soon as its
|
// graveyard and prune — so an edited-away snapshot stops costing memory as soon as its
|
||||||
@@ -376,8 +404,13 @@ private:
|
|||||||
|
|
||||||
ReaperBridge bridge_;
|
ReaperBridge bridge_;
|
||||||
|
|
||||||
// The parameter whose drag bracket is currently open, 0 for none. UI thread only.
|
// The parameters whose drag bracket is currently open, and whether a drag is in flight at
|
||||||
instrument::param::ParamId openGestureId_ = 0;
|
// all. UI thread only. Past the cap a write degrades to a one-point gesture rather than
|
||||||
|
// dropping — the pre-bracket behaviour, not a new failure mode.
|
||||||
|
static constexpr std::size_t kMaxOpenGestures = 8;
|
||||||
|
instrument::param::ParamId openGestureIds_[kMaxOpenGestures] = {};
|
||||||
|
std::size_t openGestureCount_ = 0;
|
||||||
|
bool gestureLatching_ = false;
|
||||||
// Set across setState so a LOAD is not reflected back to the host as an edit. Main thread
|
// Set across setState so a LOAD is not reflected back to the host as an edit. Main thread
|
||||||
// only, and non-atomic on purpose: the SDK calls setState there and nowhere else.
|
// only, and non-atomic on purpose: the SDK calls setState there and nowhere else.
|
||||||
bool paramNotifySuppressed_ = false;
|
bool paramNotifySuppressed_ = false;
|
||||||
@@ -387,6 +420,26 @@ private:
|
|||||||
// Both live_ and draining_ observe this same block — a block owned by a snapshot would
|
// Both live_ and draining_ observe this same block — a block owned by a snapshot would
|
||||||
// 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 audio thread, which merges liveParams_ with the host's automation points once per
|
||||||
|
// block and republishes ONLY when either side moved — so a block carrying neither costs one
|
||||||
|
// relaxed load and the engine's own read shape is unchanged. Two blocks because the seqlock's
|
||||||
|
// single-writer contract is load-bearing and the two writers differ in thread; this
|
||||||
|
// directory's CLAUDE.md owns the argument.
|
||||||
|
instrument::engine::LiveParams automationLive_;
|
||||||
|
// Audio thread only. The last liveParams_ generation merged, and the sticky automation values
|
||||||
|
// re-applied over every merge — without them a model republish (any knob move) would revert
|
||||||
|
// an automated parameter until its lane's next point.
|
||||||
|
std::uint32_t seenModelGeneration_ = 0;
|
||||||
|
static constexpr std::size_t kDeckParamSlots =
|
||||||
|
static_cast<std::size_t>(instrument::ui::DeckParam::kCount);
|
||||||
|
double automationNorm_[kDeckParamSlots] = {};
|
||||||
|
bool automationHeld_[kDeckParamSlots] = {};
|
||||||
|
// The audio thread's publication of those values to the UI thread's fold. automationAny_
|
||||||
|
// makes the idle drain a single load.
|
||||||
|
std::atomic<double> automationPublished_[kDeckParamSlots] = {};
|
||||||
|
std::atomic<bool> automationPending_[kDeckParamSlots] = {};
|
||||||
|
std::atomic<bool> automationAny_{false};
|
||||||
// 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
|
||||||
@@ -435,6 +488,10 @@ private:
|
|||||||
// the bake's reset, which the old capture would be the wrong thing to apply it to.
|
// the bake's reset, which the old capture would be the wrong thing to apply it to.
|
||||||
// Guarded by reloadMutex_, consumed by publishBuiltLocked.
|
// Guarded by reloadMutex_, consumed by publishBuiltLocked.
|
||||||
std::optional<double> gainAtNextPublish_;
|
std::optional<double> gainAtNextPublish_;
|
||||||
|
// Armed when that deferred gain lands, delivered by flushGainNotify once reloadMutex_ is
|
||||||
|
// released. Same shape and same reason as latencyRestartPending_ (see it): a host handler
|
||||||
|
// callback must never run inside this mutex.
|
||||||
|
std::atomic<bool> gainNotifyPending_{false};
|
||||||
|
|
||||||
// The decoded PCM parked across a deactivate, so an activation cycle costs no disk read
|
// The decoded PCM parked across a deactivate, so an activation cycle costs no disk read
|
||||||
// and no WAV decode: activation is "the audio thread may run", not "the sample is
|
// and no WAV decode: activation is "the audio thread may run", not "the sample is
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ int main() {
|
|||||||
{
|
{
|
||||||
PlayParams slow = s.play;
|
PlayParams slow = s.play;
|
||||||
slow.trigAhd.attackFrames = 900;
|
slow.trigAhd.attackFrames = 900;
|
||||||
block.publish(instrument::engine::foldLive(slow));
|
block.publish(instrument::engine::foldLive(slow, s.keyTrack));
|
||||||
}
|
}
|
||||||
|
|
||||||
const BakePlan plan = planOf(/*total=*/1000, /*noteOn=*/0, /*noteOff=*/1000);
|
const BakePlan plan = planOf(/*total=*/1000, /*noteOn=*/0, /*noteOff=*/1000);
|
||||||
|
|||||||
@@ -49,9 +49,10 @@ static void testEveryDeckControlIsClassifiedIntoOneOfTheThreeCommitTiers() {
|
|||||||
|
|
||||||
// The note-on-latched tier: published like a live control, read only at note-on. Asserted as
|
// The note-on-latched tier: published like a live control, read only at note-on. Asserted as
|
||||||
// its OWN state rather than as "not Reload" — the whole point of widening the predicate is
|
// its OWN state rather than as "not Reload" — the whole point of widening the predicate is
|
||||||
// that Rate must not fall back into either neighbour, and Γ-W4-T1 reads this classification
|
// that none of these falls back into either neighbour, and the VST3 parameter surface reads
|
||||||
// to decide what it exposes to the host.
|
// this classification to decide what it exposes to the host.
|
||||||
const DeckParam latched[] = {DeckParam::kRate};
|
const DeckParam latched[] = {DeckParam::kRate, DeckParam::kKeyTrack,
|
||||||
|
DeckParam::kTrigLength};
|
||||||
for (DeckParam p : latched) CHECK(deckParamCommit(p) == LiveCommit::NoteOnLatched);
|
for (DeckParam p : latched) CHECK(deckParamCommit(p) == LiveCommit::NoteOnLatched);
|
||||||
|
|
||||||
// Everything else reloads or rebuilds; deck_groups.h is the home for why each exclusion
|
// Everything else reloads or rebuilds; deck_groups.h is the home for why each exclusion
|
||||||
@@ -60,7 +61,6 @@ static void testEveryDeckControlIsClassifiedIntoOneOfTheThreeCommitTiers() {
|
|||||||
DeckParam::kPlayMode, DeckParam::kPitchEngine, DeckParam::kPitchEnvEnable,
|
DeckParam::kPlayMode, DeckParam::kPitchEngine, DeckParam::kPitchEnvEnable,
|
||||||
DeckParam::kFilterEnable, DeckParam::kFilterLaw,
|
DeckParam::kFilterEnable, DeckParam::kFilterLaw,
|
||||||
DeckParam::kAmpVelCurve, DeckParam::kPitchVelCurve, DeckParam::kFilterVelCurve,
|
DeckParam::kAmpVelCurve, DeckParam::kPitchVelCurve, DeckParam::kFilterVelCurve,
|
||||||
DeckParam::kKeyTrack, DeckParam::kTrigLength,
|
|
||||||
DeckParam::kAmpEnvSelect, DeckParam::kPitchEnvSelect, DeckParam::kFilterEnvSelect,
|
DeckParam::kAmpEnvSelect, DeckParam::kPitchEnvSelect, DeckParam::kFilterEnvSelect,
|
||||||
DeckParam::kAmpEnvMode, DeckParam::kPitchEnvMode, DeckParam::kFilterEnvMode,
|
DeckParam::kAmpEnvMode, DeckParam::kPitchEnvMode, DeckParam::kFilterEnvMode,
|
||||||
DeckParam::kVoiceCount, DeckParam::kVoiceMode,
|
DeckParam::kVoiceCount, DeckParam::kVoiceMode,
|
||||||
@@ -99,7 +99,10 @@ static void testOnlyALiveControlsDragTakesTheLiveTier() {
|
|||||||
// Rate keeps its own tier through the drag site: it must not arrive as Live (which would let
|
// Rate keeps its own tier through the drag site: it must not arrive as Live (which would let
|
||||||
// it move a sounding note) nor as Reload (which would re-decode the WAV under a swept knob).
|
// it move a sounding note) nor as Reload (which would re-decode the WAV under a swept knob).
|
||||||
CHECK(knob(DeckParam::kRate) == LiveCommit::NoteOnLatched);
|
CHECK(knob(DeckParam::kRate) == LiveCommit::NoteOnLatched);
|
||||||
CHECK(knob(DeckParam::kTrigLength) == LiveCommit::Reload);
|
// Trigger length resolves playEnd_ and key-track the pitch ratio — both facts a voice fixes
|
||||||
|
// at note-on, which is the latched tier's own definition rather than the reload tier's.
|
||||||
|
CHECK(knob(DeckParam::kTrigLength) == LiveCommit::NoteOnLatched);
|
||||||
|
CHECK(knob(DeckParam::kKeyTrack) == LiveCommit::NoteOnLatched);
|
||||||
// Master gain is Live and reaches the audio BESIDE the live block rather than through it —
|
// Master gain is Live and reaches the audio BESIDE the live block rather than through it —
|
||||||
// one atomic the audio thread applies as a post-sum multiply. Classifying it Reload would
|
// one atomic the audio thread applies as a post-sum multiply. Classifying it Reload would
|
||||||
// claim a gain move re-decodes the WAV, which it never did.
|
// claim a gain move re-decodes the WAV, which it never did.
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ static Run renderWithLive(SampleData& sample, LiveParams* block, int blockFrames
|
|||||||
int changeAfter, const LiveValues* changed, int noteOffBlock = -1,
|
int changeAfter, const LiveValues* changed, int noteOffBlock = -1,
|
||||||
int velocity = 100) {
|
int velocity = 100) {
|
||||||
sample.live = block;
|
sample.live = block;
|
||||||
if (block) block->publish(foldLive(sample.play));
|
if (block) block->publish(foldLive(sample.play, sample.keyTrack));
|
||||||
VoiceEngine engine(1, sample);
|
VoiceEngine engine(1, sample);
|
||||||
engine.noteOn(kTestNote, velocity);
|
engine.noteOn(kTestNote, velocity);
|
||||||
Run r;
|
Run r;
|
||||||
@@ -320,7 +320,7 @@ static void testANoteStartedAfterAPublishSoundsThePublishedEnvelope() {
|
|||||||
SampleData s = periodicSine(200000, 64.0); // adsr default: attack 0, sustain 1.0
|
SampleData s = periodicSine(200000, 64.0); // adsr default: attack 0, sustain 1.0
|
||||||
LiveParams block;
|
LiveParams block;
|
||||||
s.live = █
|
s.live = █
|
||||||
LiveValues dialled = foldLive(s.play);
|
LiveValues dialled = foldLive(s.play, s.keyTrack);
|
||||||
dialled.adsr.attackFrames = 24000; // half a second of attack, dialled before the note
|
dialled.adsr.attackFrames = 24000; // half a second of attack, dialled before the note
|
||||||
block.publish(dialled);
|
block.publish(dialled);
|
||||||
VoiceEngine engine(1, s);
|
VoiceEngine engine(1, s);
|
||||||
@@ -345,7 +345,7 @@ static void testANoteStartedAfterAPublishSoundsThePublishedEnvelope() {
|
|||||||
slow.play.adsr.attackFrames = 24000;
|
slow.play.adsr.attackFrames = 24000;
|
||||||
LiveParams block2;
|
LiveParams block2;
|
||||||
slow.live = &block2;
|
slow.live = &block2;
|
||||||
LiveValues snappy = foldLive(slow.play);
|
LiveValues snappy = foldLive(slow.play, slow.keyTrack);
|
||||||
snappy.adsr.attackFrames = 0;
|
snappy.adsr.attackFrames = 0;
|
||||||
block2.publish(snappy);
|
block2.publish(snappy);
|
||||||
VoiceEngine fast(1, slow);
|
VoiceEngine fast(1, slow);
|
||||||
@@ -368,7 +368,7 @@ static void assertLiveFieldMovesTheSoundingNote(const char* name, void (*rig)(Sa
|
|||||||
rig(still);
|
rig(still);
|
||||||
rig(moved);
|
rig(moved);
|
||||||
LiveParams blockA, blockB;
|
LiveParams blockA, blockB;
|
||||||
LiveValues target = foldLive(moved.play);
|
LiveValues target = foldLive(moved.play, moved.keyTrack);
|
||||||
mutate(target);
|
mutate(target);
|
||||||
|
|
||||||
const Run baseline = renderWithLive(still, &blockA, 512, 24, -1, nullptr, noteOffBlock);
|
const Run baseline = renderWithLive(still, &blockA, 512, 24, -1, nullptr, noteOffBlock);
|
||||||
@@ -616,7 +616,7 @@ static void testEveryLiveFilterControlMovesTheSoundingNote() {
|
|||||||
SampleData still = filteredSine();
|
SampleData still = filteredSine();
|
||||||
SampleData moved = filteredSine();
|
SampleData moved = filteredSine();
|
||||||
LiveParams blockA, blockB;
|
LiveParams blockA, blockB;
|
||||||
LiveValues target = foldLive(moved.play);
|
LiveValues target = foldLive(moved.play, moved.keyTrack);
|
||||||
c.mutate(target);
|
c.mutate(target);
|
||||||
|
|
||||||
const Run baseline = renderWithLive(still, &blockA, 512, 24, -1, nullptr);
|
const Run baseline = renderWithLive(still, &blockA, 512, 24, -1, nullptr);
|
||||||
@@ -688,7 +688,7 @@ static void testOneBlockServesTwoIndependentObservers() {
|
|||||||
LiveParams block;
|
LiveParams block;
|
||||||
liveSnapshot.live = █
|
liveSnapshot.live = █
|
||||||
drainSnapshot.live = █
|
drainSnapshot.live = █
|
||||||
block.publish(foldLive(liveSnapshot.play));
|
block.publish(foldLive(liveSnapshot.play, liveSnapshot.keyTrack));
|
||||||
|
|
||||||
VoiceEngine liveEngine(1, liveSnapshot);
|
VoiceEngine liveEngine(1, liveSnapshot);
|
||||||
VoiceEngine drainEngine(1, drainSnapshot);
|
VoiceEngine drainEngine(1, drainSnapshot);
|
||||||
@@ -696,7 +696,7 @@ static void testOneBlockServesTwoIndependentObservers() {
|
|||||||
drainEngine.noteOn(60, 100);
|
drainEngine.noteOn(60, 100);
|
||||||
|
|
||||||
std::vector<AudioSample> a, b;
|
std::vector<AudioSample> a, b;
|
||||||
LiveValues moved = foldLive(liveSnapshot.play);
|
LiveValues moved = foldLive(liveSnapshot.play, liveSnapshot.keyTrack);
|
||||||
moved.filterSettings.cutoffNorm = 0.2f;
|
moved.filterSettings.cutoffNorm = 0.2f;
|
||||||
for (int blk = 0; blk < 24; ++blk) {
|
for (int blk = 0; blk < 24; ++blk) {
|
||||||
if (blk == 8) block.publish(moved);
|
if (blk == 8) block.publish(moved);
|
||||||
@@ -746,7 +746,7 @@ static std::vector<AudioSample> renderPreserveCapable(SampleData& s, LiveParams&
|
|||||||
const LiveValues* changed, int changeAfter,
|
const LiveValues* changed, int changeAfter,
|
||||||
int note) {
|
int note) {
|
||||||
s.live = █
|
s.live = █
|
||||||
block.publish(foldLive(s.play));
|
block.publish(foldLive(s.play, s.keyTrack));
|
||||||
VoiceEngine engine(1, s, /*preserveVoiceCap=*/0, /*preserveWindowFrames=*/2048);
|
VoiceEngine engine(1, s, /*preserveVoiceCap=*/0, /*preserveWindowFrames=*/2048);
|
||||||
engine.noteOn(note, 100);
|
engine.noteOn(note, 100);
|
||||||
std::vector<AudioSample> out;
|
std::vector<AudioSample> out;
|
||||||
@@ -765,7 +765,7 @@ static void testARateChangeSpareTheSoundingNoteAndReachesTheNextOne() {
|
|||||||
moved.play.pitchEngine = eng;
|
moved.play.pitchEngine = eng;
|
||||||
|
|
||||||
LiveParams blockA, blockB;
|
LiveParams blockA, blockB;
|
||||||
LiveValues halfRate = foldLive(moved.play);
|
LiveValues halfRate = foldLive(moved.play, moved.keyTrack);
|
||||||
halfRate.playRate = 0.5;
|
halfRate.playRate = 0.5;
|
||||||
|
|
||||||
// At the ROOT note, so Preserve's shifter runs at shift 1.0 and never splices — the
|
// At the ROOT note, so Preserve's shifter runs at shift 1.0 and never splices — the
|
||||||
@@ -795,7 +795,7 @@ static void testARateChangeSpareTheSoundingNoteAndReachesTheNextOne() {
|
|||||||
fresh.play.pitchEngine = eng;
|
fresh.play.pitchEngine = eng;
|
||||||
LiveParams block;
|
LiveParams block;
|
||||||
fresh.live = █
|
fresh.live = █
|
||||||
LiveValues published = foldLive(fresh.play);
|
LiveValues published = foldLive(fresh.play, fresh.keyTrack);
|
||||||
published.playRate = rate;
|
published.playRate = rate;
|
||||||
block.publish(published);
|
block.publish(published);
|
||||||
VoiceEngine engine(1, fresh, /*preserveVoiceCap=*/0, /*preserveWindowFrames=*/2048);
|
VoiceEngine engine(1, fresh, /*preserveVoiceCap=*/0, /*preserveWindowFrames=*/2048);
|
||||||
@@ -827,7 +827,7 @@ static void testAPitchOffsetChangeMovesTheSoundingNoteInBothEngines() {
|
|||||||
moved.play.pitchEngine = eng;
|
moved.play.pitchEngine = eng;
|
||||||
|
|
||||||
LiveParams blockA, blockB;
|
LiveParams blockA, blockB;
|
||||||
LiveValues target = foldLive(moved.play);
|
LiveValues target = foldLive(moved.play, moved.keyTrack);
|
||||||
target.pitchOffsetSemitones = -12.0;
|
target.pitchOffsetSemitones = -12.0;
|
||||||
|
|
||||||
const std::vector<AudioSample> baseline =
|
const std::vector<AudioSample> baseline =
|
||||||
@@ -859,7 +859,7 @@ static void testAPitchOffsetChangeMovesTheSoundingNoteInBothEngines() {
|
|||||||
static std::size_t soundingBlocksWithPublishedPitch(SampleData& s, double offsetSemis,
|
static std::size_t soundingBlocksWithPublishedPitch(SampleData& s, double offsetSemis,
|
||||||
std::size_t capFrames) {
|
std::size_t capFrames) {
|
||||||
LiveParams block;
|
LiveParams block;
|
||||||
LiveValues v = foldLive(s.play); // s.play keeps its own (zero) offset: the stale copy
|
LiveValues v = foldLive(s.play, s.keyTrack); // s.play keeps its own (zero) offset: the stale copy
|
||||||
v.pitchOffsetSemitones = offsetSemis;
|
v.pitchOffsetSemitones = offsetSemis;
|
||||||
block.publish(v);
|
block.publish(v);
|
||||||
s.live = █
|
s.live = █
|
||||||
@@ -891,7 +891,7 @@ static void testAPublishedPitchOffsetLeavesTheStagedAttackWallClock() {
|
|||||||
s.play.trigAhd = AhdParams{kAttack, 0, 1.0, util::kCurveNeutral, util::kCurveNeutral};
|
s.play.trigAhd = AhdParams{kAttack, 0, 1.0, util::kCurveNeutral, util::kCurveNeutral};
|
||||||
|
|
||||||
LiveParams block;
|
LiveParams block;
|
||||||
LiveValues v = foldLive(s.play);
|
LiveValues v = foldLive(s.play, s.keyTrack);
|
||||||
v.pitchOffsetSemitones = semis;
|
v.pitchOffsetSemitones = semis;
|
||||||
block.publish(v);
|
block.publish(v);
|
||||||
s.live = █
|
s.live = █
|
||||||
@@ -960,12 +960,12 @@ static void testPitchRatioAndVelocityGainStayLatched() {
|
|||||||
|
|
||||||
LiveParams block;
|
LiveParams block;
|
||||||
s.live = █
|
s.live = █
|
||||||
block.publish(foldLive(s.play));
|
block.publish(foldLive(s.play, s.keyTrack));
|
||||||
VoiceEngine engine(1, s);
|
VoiceEngine engine(1, s);
|
||||||
engine.noteOn(72, 64); // an octave up: ratio 2.0
|
engine.noteOn(72, 64); // an octave up: ratio 2.0
|
||||||
|
|
||||||
std::vector<AudioSample> out;
|
std::vector<AudioSample> out;
|
||||||
LiveValues hostile = foldLive(s.play);
|
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. None of it names velocity, the
|
||||||
// note, the pitch ratio, or the PCM — that is the property under test.
|
// note, the pitch ratio, or the PCM — that is the property under test.
|
||||||
hostile.filterKeyTrack = 2.0;
|
hostile.filterKeyTrack = 2.0;
|
||||||
@@ -998,11 +998,11 @@ static void testPitchRatioAndVelocityGainStayLatched() {
|
|||||||
SampleData s2 = s;
|
SampleData s2 = s;
|
||||||
LiveParams block2;
|
LiveParams block2;
|
||||||
s2.live = &block2;
|
s2.live = &block2;
|
||||||
block2.publish(foldLive(s2.play));
|
block2.publish(foldLive(s2.play, s2.keyTrack));
|
||||||
VoiceEngine engine2(1, s2);
|
VoiceEngine engine2(1, s2);
|
||||||
engine2.noteOn(72, 64);
|
engine2.noteOn(72, 64);
|
||||||
std::vector<AudioSample> out2;
|
std::vector<AudioSample> out2;
|
||||||
LiveValues quieter = foldLive(s2.play);
|
LiveValues quieter = foldLive(s2.play, s2.keyTrack);
|
||||||
quieter.adsr.sustainLevel = 0.25;
|
quieter.adsr.sustainLevel = 0.25;
|
||||||
for (int blk = 0; blk < 8; ++blk) {
|
for (int blk = 0; blk < 8; ++blk) {
|
||||||
if (blk == 2) block2.publish(quieter);
|
if (blk == 2) block2.publish(quieter);
|
||||||
@@ -1024,7 +1024,7 @@ static void testVelocityGainSurvivesAHostilePublishThatReallyLands() {
|
|||||||
rig.play.pitchEnv.shape.decayFrames = 24000;
|
rig.play.pitchEnv.shape.decayFrames = 24000;
|
||||||
rig.play.pitchEnv.peakSemitones = 3.0;
|
rig.play.pitchEnv.peakSemitones = 3.0;
|
||||||
|
|
||||||
LiveValues hostile = foldLive(rig.play);
|
LiveValues hostile = foldLive(rig.play, rig.keyTrack);
|
||||||
hostile.filterKeyTrack = 2.0;
|
hostile.filterKeyTrack = 2.0;
|
||||||
hostile.filterSettings.cutoffNorm = 0.9f;
|
hostile.filterSettings.cutoffNorm = 0.9f;
|
||||||
hostile.filterModAmount = -1.0;
|
hostile.filterModAmount = -1.0;
|
||||||
|
|||||||
@@ -41,7 +41,9 @@ static void testFoldCarriesEveryContinuousControl() {
|
|||||||
p.pitchEnv.shape = AhdParams{7, 9, 0.4, 1.5, 0.75};
|
p.pitchEnv.shape = AhdParams{7, 9, 0.4, 1.5, 0.75};
|
||||||
p.pitchEnv.peakSemitones = -3.5;
|
p.pitchEnv.peakSemitones = -3.5;
|
||||||
|
|
||||||
const LiveValues v = foldLive(p);
|
// Distinct from filter.keyTrack below on purpose: the two are different controls and a fold
|
||||||
|
// that crossed them would pass under a shared value.
|
||||||
|
const LiveValues v = foldLive(p, /*keyTrack=*/0.8);
|
||||||
CHECK(v.adsr.attackFrames == 11);
|
CHECK(v.adsr.attackFrames == 11);
|
||||||
CHECK(v.adsr.holdFrames == 22);
|
CHECK(v.adsr.holdFrames == 22);
|
||||||
CHECK(v.adsr.decayFrames == 33);
|
CHECK(v.adsr.decayFrames == 33);
|
||||||
@@ -72,6 +74,21 @@ static void testFoldCarriesEveryContinuousControl() {
|
|||||||
CHECK(v.pitchEnv.shape.attackCurve == 1.5);
|
CHECK(v.pitchEnv.shape.attackCurve == 1.5);
|
||||||
CHECK(v.pitchEnv.shape.decayCurve == 0.75);
|
CHECK(v.pitchEnv.shape.decayCurve == 0.75);
|
||||||
CHECK(v.pitchEnv.peakSemitones == -3.5);
|
CHECK(v.pitchEnv.peakSemitones == -3.5);
|
||||||
|
CHECK(v.keyTrack == 0.8);
|
||||||
|
// Spline-folded on the way in, so the block carries what the voice will actually play.
|
||||||
|
CHECK(!v.splineActive);
|
||||||
|
CHECK(v.lengthFraction == p.trigger.lengthFraction);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The fold, not the voice, is where a drawn contour pins the Trigger span — so the block a
|
||||||
|
// note-on latches already carries the folded value.
|
||||||
|
static void testADrawnEnvelopePinsTheFoldedTriggerLength() {
|
||||||
|
PlayParams p;
|
||||||
|
p.trigger.lengthFraction = 0.25;
|
||||||
|
p.ampSpline.mode = EnvMode::Spline;
|
||||||
|
const LiveValues v = foldLive(p, kKeyTrackDefault);
|
||||||
|
CHECK(v.splineActive);
|
||||||
|
CHECK(v.lengthFraction == 1.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
static void testUnpublishedBlockReadsAsNothing() {
|
static void testUnpublishedBlockReadsAsNothing() {
|
||||||
@@ -170,6 +187,7 @@ static void testRampStepIsRateDerived() {
|
|||||||
|
|
||||||
int main() {
|
int main() {
|
||||||
testFoldCarriesEveryContinuousControl();
|
testFoldCarriesEveryContinuousControl();
|
||||||
|
testADrawnEnvelopePinsTheFoldedTriggerLength();
|
||||||
testUnpublishedBlockReadsAsNothing();
|
testUnpublishedBlockReadsAsNothing();
|
||||||
testConcurrentReaderNeverSeesAHalfAppliedEdit();
|
testConcurrentReaderNeverSeesAHalfAppliedEdit();
|
||||||
testRampTerminatesExactlyOnTheTarget();
|
testRampTerminatesExactlyOnTheTarget();
|
||||||
|
|||||||
+85
-21
@@ -1,17 +1,21 @@
|
|||||||
// Standalone tests for the ONE formatter per unit category: the digit shapes each category
|
// Standalone tests for the ONE formatter per unit category: the digit shapes each category
|
||||||
// prints, and the property that makes the editor's knob label and the host's parameter string
|
// prints, and the property that makes the editor's knob label and the host's parameter string
|
||||||
// identical — both call THIS function, over a plain value derived the way each surface derives
|
// identical — both call THIS function, each over the plain value ITS OWN surface derives (the
|
||||||
// it. No VST3, no REAPER, no framework.
|
// editor's exponent read goes to the stored field, not through the knob law). No VST3, no
|
||||||
|
// REAPER, no framework.
|
||||||
|
|
||||||
#include "../src/core/instrument/param/param_format.h"
|
#include "../src/core/instrument/param/param_format.h"
|
||||||
|
|
||||||
#include "../src/core/instrument/engine/master_gain.h"
|
#include "../src/core/instrument/engine/master_gain.h"
|
||||||
|
#include "../src/core/instrument/map/sample_map.h"
|
||||||
#include "../src/core/instrument/param/param_id.h"
|
#include "../src/core/instrument/param/param_id.h"
|
||||||
|
#include "../src/core/instrument/param/param_units.h"
|
||||||
#include "../src/core/instrument/ui/deck_values.h"
|
#include "../src/core/instrument/ui/deck_values.h"
|
||||||
|
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
#include <limits>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
using namespace reasampler;
|
using namespace reasampler;
|
||||||
@@ -61,34 +65,40 @@ static void testEachCategoryPrintsItsSpecifiedShape() {
|
|||||||
CHECK(digits(UnitKind::Dimensionless, 0.1) == "0.10");
|
CHECK(digits(UnitKind::Dimensionless, 0.1) == "0.10");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The EDITOR's derivation, spelled the way ReaSamplerEditor::deckPlainValue spells it — a curve
|
||||||
|
// exponent is read off its stored field, never round-tripped through the knob law. Driving the
|
||||||
|
// sweep through this rather than through toPlain(deckParamNorm(...)) is what makes the exponent
|
||||||
|
// half of it a real assertion instead of a round trip on both sides.
|
||||||
|
static double editorPlainValue(DeckParam deck, PlaySeconds& play) {
|
||||||
|
using reasampler::instrument::ui::UnitCategory;
|
||||||
|
if (reasampler::instrument::ui::deckParamUnit(deck) == UnitCategory::Exponent) {
|
||||||
|
const double* stored = reasampler::instrument::ui::deckDoubleField(deck, play);
|
||||||
|
return stored ? *stored : 0.0;
|
||||||
|
}
|
||||||
|
return toPlain(deck, reasampler::instrument::ui::deckParamNorm(deck, play));
|
||||||
|
}
|
||||||
|
|
||||||
static void testTheEditorAndTheHostPrintTheSameDigitsAtTheSameStoredValue() {
|
static void testTheEditorAndTheHostPrintTheSameDigitsAtTheSameStoredValue() {
|
||||||
// The host derives its plain value from the normalized one it holds; the editor derives its
|
// The host derives its plain value from the normalized one it holds; the editor derives its
|
||||||
// from the STORED field, through the deck's own read. If those two derivations disagreed at
|
// from the STORED field. If those two derivations disagreed at any reachable value the two
|
||||||
// any reachable value the two surfaces would print different numbers for one control — this
|
// surfaces would print different numbers for one control — this is that property, swept over
|
||||||
// is that property, swept over the whole travel of every exposed control.
|
// the whole travel of every exposed control the parameter set carries.
|
||||||
for (const ParamRow& row : exposedParams()) {
|
for (const ParamRow& row : exposedParams()) {
|
||||||
if (row.deck == DeckParam::kMasterGain) continue; // not stored in PlaySeconds
|
if (valueHomeFor(row.deck) == ValueHome::InstanceScalar) continue; // own tests below
|
||||||
for (int step = 0; step <= 40; ++step) {
|
for (int step = 0; step <= 40; ++step) {
|
||||||
const double norm = step / 40.0;
|
const double norm = step / 40.0;
|
||||||
PlaySeconds play;
|
PlaySeconds play;
|
||||||
reasampler::instrument::ui::setDeckParam(row.deck, play, norm, /*segment=*/0);
|
reasampler::instrument::ui::setDeckParam(row.deck, play, norm, /*segment=*/0);
|
||||||
|
const double storedNorm = reasampler::instrument::ui::deckParamNorm(row.deck, play);
|
||||||
|
|
||||||
char hostBuf[24];
|
if (row.deck == DeckParam::kFilterMorph) {
|
||||||
formatPlainFor(row.deck, toPlain(row.deck, norm), hostBuf, sizeof(hostBuf));
|
// MORPH ALONE: its toPlain is full-double (`clamp01(n) * 100`), so the float the
|
||||||
|
// model stores and the double the host holds are genuinely different inputs, and
|
||||||
const double editorNorm =
|
// at a value landing on a display rounding boundary that is worth one integer
|
||||||
reasampler::instrument::ui::deckParamNorm(row.deck, play);
|
// percent. Cutoff/Q/drive cast to float INSIDE toPlain, so they are bit-identical
|
||||||
char editorBuf[24];
|
// either way and are held to the string below like everything else.
|
||||||
formatPlainFor(row.deck, toPlain(row.deck, editorNorm), editorBuf, sizeof(editorBuf));
|
|
||||||
|
|
||||||
if (storesNormalized(row.deck)) {
|
|
||||||
// The filter's four store their position as a FLOAT, so a norm the host has sent
|
|
||||||
// but we have not yet stored differs from the stored one by up to a float ulp.
|
|
||||||
// At a value landing exactly on a display rounding boundary that is worth one
|
|
||||||
// digit, so these four are held to the PLAIN value rather than to the string —
|
|
||||||
// the derivation is still asserted to be one derivation.
|
|
||||||
const double hostPlain = toPlain(row.deck, norm);
|
const double hostPlain = toPlain(row.deck, norm);
|
||||||
const double editorPlain = toPlain(row.deck, editorNorm);
|
const double editorPlain = toPlain(row.deck, storedNorm);
|
||||||
const double tolerance = std::fabs(hostPlain) * 1e-6 + 1e-9;
|
const double tolerance = std::fabs(hostPlain) * 1e-6 + 1e-9;
|
||||||
if (std::fabs(hostPlain - editorPlain) > tolerance) {
|
if (std::fabs(hostPlain - editorPlain) > tolerance) {
|
||||||
std::printf("FAIL param %u at norm %.4f: host %.9g vs editor %.9g\n",
|
std::printf("FAIL param %u at norm %.4f: host %.9g vs editor %.9g\n",
|
||||||
@@ -97,6 +107,16 @@ static void testTheEditorAndTheHostPrintTheSameDigitsAtTheSameStoredValue() {
|
|||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// A write the model CLAMPED (Trigger length's (0,1] floor) is not a settled state:
|
||||||
|
// setParamNormalized caches what the model TOOK, so the host never holds the rejected
|
||||||
|
// value. Only the four float-stored positions differ by a ulp rather than a clamp.
|
||||||
|
if (!storesNormalized(row.deck) && storedNorm != norm) continue;
|
||||||
|
|
||||||
|
char hostBuf[24];
|
||||||
|
char editorBuf[24];
|
||||||
|
formatPlainFor(row.deck, toPlain(row.deck, norm), hostBuf, sizeof(hostBuf));
|
||||||
|
formatPlainFor(row.deck, editorPlainValue(row.deck, play), editorBuf,
|
||||||
|
sizeof(editorBuf));
|
||||||
if (std::strcmp(hostBuf, editorBuf) != 0) {
|
if (std::strcmp(hostBuf, editorBuf) != 0) {
|
||||||
std::printf("FAIL param %u at norm %.4f: host \"%s\" vs editor \"%s\"\n",
|
std::printf("FAIL param %u at norm %.4f: host \"%s\" vs editor \"%s\"\n",
|
||||||
row.id, norm, hostBuf, editorBuf);
|
row.id, norm, hostBuf, editorBuf);
|
||||||
@@ -106,6 +126,48 @@ static void testTheEditorAndTheHostPrintTheSameDigitsAtTheSameStoredValue() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The one place the two surfaces GENUINELY diverge, asserted so it stays a known property rather
|
||||||
|
// than a surprise: an exponent inside curve_law's centre detent but not exactly neutral is
|
||||||
|
// reachable only through an overlay knot drag, and the host — which holds the norm and nothing
|
||||||
|
// else — reads it back as the neutral the knob law snaps to.
|
||||||
|
static void testAnOffDetentExponentReadsNeutralToTheHostAndTrueToTheEditor() {
|
||||||
|
PlaySeconds play;
|
||||||
|
// The detent is +/-0.01 in NORM, which is a ~+/-0.047 band in the exponent — so 1.04 is
|
||||||
|
// inside it and still prints as a distinct number.
|
||||||
|
play.adsr.attackCurve = 1.04;
|
||||||
|
char editorBuf[24];
|
||||||
|
formatPlainFor(DeckParam::kAttackCurve, editorPlainValue(DeckParam::kAttackCurve, play),
|
||||||
|
editorBuf, sizeof(editorBuf));
|
||||||
|
CHECK(std::string(editorBuf) == "1.04");
|
||||||
|
const double hostNorm =
|
||||||
|
reasampler::instrument::ui::deckParamNorm(DeckParam::kAttackCurve, play);
|
||||||
|
char hostBuf[24];
|
||||||
|
formatPlainFor(DeckParam::kAttackCurve, toPlain(DeckParam::kAttackCurve, hostNorm), hostBuf,
|
||||||
|
sizeof(hostBuf));
|
||||||
|
CHECK(std::string(hostBuf) == "1.00");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testKeyTrackPrintsTheSameDigitsFromEitherSurface() {
|
||||||
|
using reasampler::instrument::map::InstrumentParams;
|
||||||
|
for (int step = 0; step <= 40; ++step) {
|
||||||
|
const double norm = step / 40.0;
|
||||||
|
InstrumentParams params;
|
||||||
|
params.keyTrack = reasampler::instrument::ui::keyTrackFromNorm(norm);
|
||||||
|
const double editorNorm = reasampler::instrument::ui::keyTrackNormFrom(params.keyTrack);
|
||||||
|
char hostBuf[24];
|
||||||
|
char editorBuf[24];
|
||||||
|
formatPlainFor(DeckParam::kKeyTrack, toPlain(DeckParam::kKeyTrack, norm), hostBuf,
|
||||||
|
sizeof(hostBuf));
|
||||||
|
formatPlainFor(DeckParam::kKeyTrack, toPlain(DeckParam::kKeyTrack, editorNorm), editorBuf,
|
||||||
|
sizeof(editorBuf));
|
||||||
|
if (std::strcmp(hostBuf, editorBuf) != 0) {
|
||||||
|
std::printf("FAIL key-track at norm %.4f: host \"%s\" vs editor \"%s\"\n",
|
||||||
|
norm, hostBuf, editorBuf);
|
||||||
|
++g_fail;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static void testMasterGainPrintsTheSameDigitsFromEitherSurface() {
|
static void testMasterGainPrintsTheSameDigitsFromEitherSurface() {
|
||||||
using reasampler::instrument::engine::masterGainLinearFromNorm;
|
using reasampler::instrument::engine::masterGainLinearFromNorm;
|
||||||
using reasampler::instrument::engine::masterGainNormFromLinear;
|
using reasampler::instrument::engine::masterGainNormFromLinear;
|
||||||
@@ -165,7 +227,9 @@ static void testEveryExposedParameterHasAFormatterThatWritesSomething() {
|
|||||||
int main() {
|
int main() {
|
||||||
testEachCategoryPrintsItsSpecifiedShape();
|
testEachCategoryPrintsItsSpecifiedShape();
|
||||||
testTheEditorAndTheHostPrintTheSameDigitsAtTheSameStoredValue();
|
testTheEditorAndTheHostPrintTheSameDigitsAtTheSameStoredValue();
|
||||||
|
testAnOffDetentExponentReadsNeutralToTheHostAndTrueToTheEditor();
|
||||||
testMasterGainPrintsTheSameDigitsFromEitherSurface();
|
testMasterGainPrintsTheSameDigitsFromEitherSurface();
|
||||||
|
testKeyTrackPrintsTheSameDigitsFromEitherSurface();
|
||||||
testTypingBackADisplayedValueLandsOnIt();
|
testTypingBackADisplayedValueLandsOnIt();
|
||||||
testAShortBufferIsNeverOverrunAndAlwaysTerminates();
|
testAShortBufferIsNeverOverrunAndAlwaysTerminates();
|
||||||
testEveryExposedParameterHasAFormatterThatWritesSomething();
|
testEveryExposedParameterHasAFormatterThatWritesSomething();
|
||||||
|
|||||||
+12
-11
@@ -150,22 +150,23 @@ static void testTheExposedSetIsExactlyThePredicateAnswer() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static void testAReservedRowIsNumberedButNotIssued() {
|
static void testEveryNumberedRowIsIssued() {
|
||||||
// Key-track (pitch) and Trigger length are note-on-latch candidates that still route through
|
// Key-track (pitch) and Trigger length were reserved-but-unissued while they routed through
|
||||||
// the reload tier, so they are NOT issued to the host today. Their numbers stay reserved
|
// the reload tier; both are note-on-latched now, so both are issued under the numbers that
|
||||||
// rather than retired: nothing shipped under them, so a later promotion issues the same id
|
// were held for them and no other id moved — which is what the block-and-step scheme bought.
|
||||||
// and no other id moves.
|
|
||||||
CHECK(paramIdFor(DeckParam::kKeyTrack) == 1000);
|
CHECK(paramIdFor(DeckParam::kKeyTrack) == 1000);
|
||||||
CHECK(paramIdFor(DeckParam::kTrigLength) == 1450);
|
CHECK(paramIdFor(DeckParam::kTrigLength) == 1450);
|
||||||
CHECK(!isExposed(DeckParam::kKeyTrack));
|
CHECK(isExposed(DeckParam::kKeyTrack));
|
||||||
CHECK(!isExposed(DeckParam::kTrigLength));
|
CHECK(isExposed(DeckParam::kTrigLength));
|
||||||
CHECK(exposedRowFor(1000) == nullptr);
|
CHECK(exposedRowFor(1000) != nullptr);
|
||||||
CHECK(exposedRowFor(1450) == nullptr);
|
CHECK(exposedRowFor(1450) != nullptr);
|
||||||
// Master gain IS issued: one atomic store the audio thread picks up next block is the live
|
// Master gain IS issued: one atomic store the audio thread picks up next block is the live
|
||||||
// tier by that tier's own definition.
|
// tier by that tier's own definition.
|
||||||
CHECK(isExposed(DeckParam::kMasterGain));
|
CHECK(isExposed(DeckParam::kMasterGain));
|
||||||
CHECK(exposedRowFor(1700) != nullptr);
|
CHECK(exposedRowFor(1700) != nullptr);
|
||||||
CHECK(exposedParams().size() == paramTable().size() - 2);
|
// The whole table is issued today — 44 of 44.
|
||||||
|
CHECK(exposedParams().size() == paramTable().size());
|
||||||
|
CHECK(exposedParams().size() == 44);
|
||||||
}
|
}
|
||||||
|
|
||||||
static void testEveryRowCarriesADistinctTitleAndShortTitle() {
|
static void testEveryRowCarriesADistinctTitleAndShortTitle() {
|
||||||
@@ -186,7 +187,7 @@ int main() {
|
|||||||
testAnInnerDialSitsBesideTheKnobItShapes();
|
testAnInnerDialSitsBesideTheKnobItShapes();
|
||||||
testABlockCarriesOnlyItsOwnGroup();
|
testABlockCarriesOnlyItsOwnGroup();
|
||||||
testTheExposedSetIsExactlyThePredicateAnswer();
|
testTheExposedSetIsExactlyThePredicateAnswer();
|
||||||
testAReservedRowIsNumberedButNotIssued();
|
testEveryNumberedRowIsIssued();
|
||||||
testEveryRowCarriesADistinctTitleAndShortTitle();
|
testEveryRowCarriesADistinctTitleAndShortTitle();
|
||||||
if (g_fail == 0) std::printf("param_id: all tests passed\n");
|
if (g_fail == 0) std::printf("param_id: all tests passed\n");
|
||||||
return g_fail == 0 ? 0 : 1;
|
return g_fail == 0 ? 0 : 1;
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
// Standalone tests for the audio thread's parameter patch. The load-bearing one is the
|
||||||
|
// EQUIVALENCE assertion: patching a control into the live block must produce, bit for bit, the
|
||||||
|
// block the model path would have folded — which is what makes a second routing table safe.
|
||||||
|
|
||||||
|
#include "../src/core/instrument/param/param_live.h"
|
||||||
|
|
||||||
|
#include "../src/core/instrument/param/param_id.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>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
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::PlaySeconds;
|
||||||
|
using reasampler::instrument::map::resolvePlay;
|
||||||
|
using reasampler::instrument::ui::DeckParam;
|
||||||
|
|
||||||
|
static int g_fail = 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)
|
||||||
|
#define CHECK(cond) do { if(!(cond)) { \
|
||||||
|
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
constexpr int kRate = 48000;
|
||||||
|
|
||||||
|
// The MODEL path, verbatim: the write the editor makes, resolved and folded the way every
|
||||||
|
// publisher folds it. This is the reference the patch is measured against.
|
||||||
|
LiveValues modelBlock(const InstrumentParams& params) {
|
||||||
|
return foldLive(resolvePlay(params.play, kRate), params.keyTrack);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A parameter set deliberately away from its defaults, so an equivalence that only holds at the
|
||||||
|
// default cannot pass. Not every field — just enough that a mis-routed patch lands on a value
|
||||||
|
// that differs from the one it should have written.
|
||||||
|
InstrumentParams dialledParams() {
|
||||||
|
InstrumentParams p;
|
||||||
|
p.keyTrack = 1.5;
|
||||||
|
p.play.adsr.attackSeconds = 0.31;
|
||||||
|
p.play.adsr.holdSeconds = 0.07;
|
||||||
|
p.play.adsr.decaySeconds = 0.44;
|
||||||
|
p.play.adsr.sustainLevel = 0.62;
|
||||||
|
p.play.adsr.releaseSeconds = 0.9;
|
||||||
|
p.play.adsr.attackCurve = 2.5;
|
||||||
|
p.play.trigger.lengthFraction = 0.4;
|
||||||
|
p.play.trigAhd.attackSeconds = 0.12;
|
||||||
|
p.play.trigAhd.holdFraction = 0.3;
|
||||||
|
p.play.playRate = 1.2;
|
||||||
|
p.play.pitchOffsetSemitones = -5.0;
|
||||||
|
p.play.pitchEnv.enabled = true;
|
||||||
|
p.play.pitchEnv.peakSemitones = 7.0;
|
||||||
|
p.play.pitchEnv.shape.attackSeconds = 0.02;
|
||||||
|
p.play.filter.enabled = true;
|
||||||
|
p.play.filter.settings.cutoffNorm = 0.42f;
|
||||||
|
p.play.filter.settings.resonanceNorm = 0.66f;
|
||||||
|
p.play.filter.modAmount = -0.4;
|
||||||
|
p.play.filter.velAmount = 0.25;
|
||||||
|
p.play.filter.keyTrack = 0.75;
|
||||||
|
p.play.filter.env.attackSeconds = 0.05;
|
||||||
|
p.play.filter.trigEnv.decaySeconds = 0.6;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
// THE assertion this module exists for. For every exposed control and several normalized
|
||||||
|
// positions: writing it through the model and folding must equal patching it into the folded
|
||||||
|
// block. Bytes, not fields — a member the patch forgot to route is caught as surely as one it
|
||||||
|
// routed to the wrong place.
|
||||||
|
static void testPatchingAControlEqualsFoldingTheModelAfterTheSameWrite() {
|
||||||
|
const double kPositions[] = {0.0, 0.137, 0.5, 0.813, 1.0};
|
||||||
|
for (const ParamRow& row : exposedParams()) {
|
||||||
|
// Master gain is not carried by the block at all — the processor's own atomic is its
|
||||||
|
// route to the audio, and the patch reports that by refusing it.
|
||||||
|
if (row.deck == DeckParam::kMasterGain) {
|
||||||
|
LiveValues block = modelBlock(dialledParams());
|
||||||
|
const LiveValues before = block;
|
||||||
|
CHECK_ID(!applyLiveParam(block, row.deck, 0.25, kRate), row.id);
|
||||||
|
CHECK_ID(std::memcmp(&before, &block, sizeof(LiveValues)) == 0, row.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (double norm : kPositions) {
|
||||||
|
InstrumentParams written = dialledParams();
|
||||||
|
if (row.deck == DeckParam::kKeyTrack) {
|
||||||
|
written.keyTrack = reasampler::instrument::ui::keyTrackFromNorm(norm);
|
||||||
|
} else {
|
||||||
|
reasampler::instrument::ui::setDeckParam(row.deck, written.play, norm,
|
||||||
|
/*segment=*/0);
|
||||||
|
}
|
||||||
|
const LiveValues expected = modelBlock(written);
|
||||||
|
|
||||||
|
LiveValues patched = modelBlock(dialledParams());
|
||||||
|
CHECK_ID(applyLiveParam(patched, row.deck, norm, kRate), row.id);
|
||||||
|
CHECK_ID(std::memcmp(&expected, &patched, sizeof(LiveValues)) == 0, row.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A drawn contour makes Trigger length inert — the editor's knob goes dead and the fold pins the
|
||||||
|
// fraction at 1.0. A host lane pointed at it must be equally inert, or automation would re-open a
|
||||||
|
// control the model says is closed.
|
||||||
|
static void testTriggerLengthIsInertUnderADrawnEnvelope() {
|
||||||
|
InstrumentParams p = dialledParams();
|
||||||
|
p.play.ampSpline.mode = reasampler::EnvMode::Spline;
|
||||||
|
LiveValues block = modelBlock(p);
|
||||||
|
CHECK(block.splineActive);
|
||||||
|
CHECK(block.lengthFraction == 1.0);
|
||||||
|
CHECK(applyLiveParam(block, DeckParam::kTrigLength, 0.2, kRate));
|
||||||
|
CHECK(block.lengthFraction == 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A control with no parameter row is refused rather than silently landing somewhere.
|
||||||
|
static void testAnUnexposedControlIsRefused() {
|
||||||
|
LiveValues block = modelBlock(dialledParams());
|
||||||
|
const LiveValues before = block;
|
||||||
|
CHECK(!applyLiveParam(block, DeckParam::kPlayMode, 1.0, kRate));
|
||||||
|
CHECK(!applyLiveParam(block, DeckParam::kVoiceCount, 1.0, kRate));
|
||||||
|
CHECK(std::memcmp(&before, &block, sizeof(LiveValues)) == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every exposed control resolves to a home the host's read and write paths actually reach. The
|
||||||
|
// guard the promotion of pitch key-track needed: id 1000 was issued against a control whose value
|
||||||
|
// is not in PlaySeconds, and nothing failed to compile.
|
||||||
|
static void testEveryExposedControlHasAValueHome() {
|
||||||
|
for (const ParamRow& row : exposedParams()) {
|
||||||
|
CHECK_ID(valueHomeFor(row.deck) != ValueHome::None, row.id);
|
||||||
|
}
|
||||||
|
// And the instance-scalar set is exactly the two the shell branches on by name.
|
||||||
|
CHECK(valueHomeFor(DeckParam::kMasterGain) == ValueHome::InstanceScalar);
|
||||||
|
CHECK(valueHomeFor(DeckParam::kKeyTrack) == ValueHome::InstanceScalar);
|
||||||
|
int instanceScalars = 0;
|
||||||
|
for (const ParamRow& row : paramTable()) {
|
||||||
|
if (valueHomeFor(row.deck) == ValueHome::InstanceScalar) ++instanceScalars;
|
||||||
|
}
|
||||||
|
CHECK(instanceScalars == 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
testPatchingAControlEqualsFoldingTheModelAfterTheSameWrite();
|
||||||
|
testTriggerLengthIsInertUnderADrawnEnvelope();
|
||||||
|
testAnUnexposedControlIsRefused();
|
||||||
|
testEveryExposedControlHasAValueHome();
|
||||||
|
if (g_fail == 0) std::printf("param_live: all tests passed\n");
|
||||||
|
return g_fail == 0 ? 0 : 1;
|
||||||
|
}
|
||||||
@@ -6,7 +6,9 @@
|
|||||||
|
|
||||||
#include "../src/core/instrument/param/param_id.h"
|
#include "../src/core/instrument/param/param_id.h"
|
||||||
#include "../src/core/instrument/engine/filter/filter_params.h"
|
#include "../src/core/instrument/engine/filter/filter_params.h"
|
||||||
|
#include "../src/core/instrument/engine/master_gain.h"
|
||||||
#include "../src/core/instrument/map/play_seconds.h"
|
#include "../src/core/instrument/map/play_seconds.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 <cmath>
|
#include <cmath>
|
||||||
@@ -29,6 +31,7 @@ static void testEveryUnitStringAndRangeMatchesTheSpecifiedTable() {
|
|||||||
// docs/product/parameter-automation.md 6.7.1, per parameter rather than per category, so a
|
// docs/product/parameter-automation.md 6.7.1, per parameter rather than per category, so a
|
||||||
// control silently reclassified into the wrong category fails here.
|
// control silently reclassified into the wrong category fails here.
|
||||||
const Expect kExpected[] = {
|
const Expect kExpected[] = {
|
||||||
|
{kParamKeyTrackPitch, "%", 0.0, 200.0},
|
||||||
{kParamRate, "%", 50.0, 200.0},
|
{kParamRate, "%", 50.0, 200.0},
|
||||||
{kParamPitchOffset, "st", -24.0, 24.0},
|
{kParamPitchOffset, "st", -24.0, 24.0},
|
||||||
{kParamPitchEnvAttack, "ms", 0.0, 10000.0},
|
{kParamPitchEnvAttack, "ms", 0.0, 10000.0},
|
||||||
@@ -65,6 +68,7 @@ static void testEveryUnitStringAndRangeMatchesTheSpecifiedTable() {
|
|||||||
{kParamAmpSustain, "%", 0.0, 100.0},
|
{kParamAmpSustain, "%", 0.0, 100.0},
|
||||||
{kParamAmpRelease, "ms", 0.0, 10000.0},
|
{kParamAmpRelease, "ms", 0.0, 10000.0},
|
||||||
{kParamAmpReleaseCurve, "", 0.1, 10.0},
|
{kParamAmpReleaseCurve, "", 0.1, 10.0},
|
||||||
|
{kParamTriggerLength, "%", 0.0, 100.0},
|
||||||
{kParamAmpTrigAttack, "ms", 0.0, 10000.0},
|
{kParamAmpTrigAttack, "ms", 0.0, 10000.0},
|
||||||
{kParamAmpTrigAttackCurve, "", 0.1, 10.0},
|
{kParamAmpTrigAttackCurve, "", 0.1, 10.0},
|
||||||
{kParamAmpTrigHold, "%", 0.0, 100.0},
|
{kParamAmpTrigHold, "%", 0.0, 100.0},
|
||||||
@@ -100,6 +104,38 @@ static void testEveryDefaultHasAnExactNormalizedPreimage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void testTheHostAndTheEditorAgreeOnEveryDefaultPosition() {
|
||||||
|
// The criterion is that a host's reset-to-default and the editor's double-click land on the
|
||||||
|
// SAME value — and those are different code paths: ParameterInfo::defaultNormalizedValue
|
||||||
|
// comes from defaultNormalized (a per-CATEGORY switch), the editor's needle from
|
||||||
|
// deckParamNorm (a per-ID one). Asserting the param module against itself would not see the
|
||||||
|
// two disagree, and they carry three independently written full scales to disagree about.
|
||||||
|
using reasampler::instrument::ui::deckParamNorm;
|
||||||
|
const PlaySeconds defaults;
|
||||||
|
for (const ParamRow& row : exposedParams()) {
|
||||||
|
const double hostNorm = defaultNormalized(row.deck);
|
||||||
|
// The four that STORE their normalized position take it verbatim on both surfaces, and
|
||||||
|
// the two instance scalars are not in PlaySeconds at all — each read where it lives.
|
||||||
|
double editorNorm = 0.0;
|
||||||
|
switch (valueHomeFor(row.deck)) {
|
||||||
|
case ValueHome::InstanceScalar:
|
||||||
|
editorNorm = (row.deck == DeckParam::kMasterGain)
|
||||||
|
? reasampler::instrument::engine::masterGainNormFromLinear(1.0) // unity
|
||||||
|
: reasampler::instrument::ui::keyTrackNormFrom(
|
||||||
|
reasampler::instrument::map::InstrumentParams{}.keyTrack);
|
||||||
|
break;
|
||||||
|
case ValueHome::ParamSetNorm:
|
||||||
|
case ValueHome::ParamSet:
|
||||||
|
editorNorm = deckParamNorm(row.deck, defaults);
|
||||||
|
break;
|
||||||
|
case ValueHome::None:
|
||||||
|
CHECK_ID(false, row.id); // an exposed control with no home reads nothing
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
CHECK_ID(hostNorm == editorNorm, 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)).
|
||||||
@@ -181,6 +217,7 @@ static void testTheAddedDriveInverseUndoesTheFrozenLaw() {
|
|||||||
int main() {
|
int main() {
|
||||||
testEveryUnitStringAndRangeMatchesTheSpecifiedTable();
|
testEveryUnitStringAndRangeMatchesTheSpecifiedTable();
|
||||||
testEveryDefaultHasAnExactNormalizedPreimage();
|
testEveryDefaultHasAnExactNormalizedPreimage();
|
||||||
|
testTheHostAndTheEditorAgreeOnEveryDefaultPosition();
|
||||||
testTheFiltersFourTakeTheirStoredNormVerbatim();
|
testTheFiltersFourTakeTheirStoredNormVerbatim();
|
||||||
testToPlainIsMonotoneAcrossTheWholeTravel();
|
testToPlainIsMonotoneAcrossTheWholeTravel();
|
||||||
testTheEndpointsAreTheDeclaredPlainRange();
|
testTheEndpointsAreTheDeclaredPlainRange();
|
||||||
|
|||||||
Reference in New Issue
Block a user