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:
2026-08-02 16:22:17 -04:00
parent bfaa0f2614
commit de5654fb6f
44 changed files with 1205 additions and 302 deletions
+2 -2
View File
@@ -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
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:
- **`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
# 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
filter_params.cpp
filter_coeffs.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
# alone owns the analytic reference and the steady-state gain measurement — a forked copy of
+6 -1
View File
@@ -5,8 +5,13 @@
namespace reasampler::instrument::engine {
LiveValues foldLive(const PlayParams& params) {
LiveValues foldLive(const PlayParams& params, double keyTrack) {
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.filterModAmount = params.filter.modAmount;
v.filterVelAmount = params.filter.velAmount;
+21 -2
View File
@@ -52,6 +52,19 @@ struct LiveValues {
// ordinarily live.
double playRate = 1.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.
@@ -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 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.
LiveValues foldLive(const PlayParams& params);
// here so there is a single site to keep in step with PlayParams. `keyTrack` is passed in
// because it belongs to the capture/instrument scalar beside the play bundle, not to
// 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
// 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
}
// 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
// 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).
@@ -17,6 +17,13 @@ double masterGainMaxLinear() { return std::pow(10.0, kMasterGainMaxDb / 20.0); }
double masterGainDbFromNorm(double norm) {
norm = clamp01(norm);
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);
}
+6 -1
View File
@@ -148,6 +148,11 @@ struct FilterParams {
// with the pitch envelope's own depth throw so the two pitch modulators speak one range.
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,
// 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
@@ -267,7 +272,7 @@ struct SampleData {
// 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
// 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
// (never per frame). Default flat y=1 — every velocity plays at unity.
+5 -5
View File
@@ -19,7 +19,7 @@ void Voice::presizePreserveShifters(std::int64_t windowFrames) {
}
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
// 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
@@ -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.
preserveRead_ = (pitchEngine_ == PitchEngine::Preserve) && shiftL_.configured();
rateRatio_ = preserveRead_ ? 1.0 : stretchRate_;
keyTrack_ = (keyTrack < 0.0) ? sample.keyTrack : keyTrack;
recomputeBaseRatio();
// 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.
@@ -120,10 +121,9 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
} else {
// 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/. The spline fold is effectiveLengthFraction (play_params.h); a second copy of it
// here is what let a stored-but-inert %-knob shorten the bake while the voice played
// the whole take.
double frac = effectiveLengthFraction(p);
// map/. The caller's value is ALREADY spline-folded (foldLive does it); the snapshot
// fallback folds here, because a stored-but-inert %-knob must not shorten the span.
double frac = (lengthFraction < 0.0) ? effectiveLengthFraction(p) : lengthFraction;
if (!(frac > 0.0)) frac = 0.0; // %=0 (or a corrupt NaN) -> finishes immediately
if (frac > 1.0) frac = 1.0;
std::int64_t playLen = static_cast<std::int64_t>(
+17 -2
View File
@@ -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
// 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.
// "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) {
const double semis = static_cast<double>(note - rootNote) * keyTrack;
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 —
// sample.play.playRate is NOT read here, because the published block outranks the snapshot's
// 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,
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
// 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.
void recomputeBaseRatio() {
if (sample_ == nullptr) return;
baseRatio_ = keyTrackedRatio(note_, sample_->rootNote, sample_->keyTrack) *
baseRatio_ = keyTrackedRatio(note_, sample_->rootNote, keyTrack_) *
velPitchRatio_ * pitchOffsetRatio_ * rateRatio_;
}
@@ -710,6 +721,10 @@ private:
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 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
// 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.
+7 -2
View File
@@ -56,9 +56,14 @@ void VoiceEngine::startVoice(Voice& voice, int note, int velocity) {
refreshLive();
// 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
// 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;
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);
voice.setStartOrder(nextStartOrder_++);
}
+13
View File
@@ -5,12 +5,25 @@
// 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.
#include <cstdint>
#include "core/instrument/engine/play_params.h" // PlayMode / TriggerParams / SplineEnv / …
namespace reasampler::instrument::map {
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
// 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
+1 -5
View File
@@ -215,11 +215,7 @@ PlayParams resolvePlay(const PlaySeconds& stored, int sampleRate) {
// carries through untouched, already a fraction.
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 auto secToFrames = [sr](double sec) {
double f = sec * sr;
if (f < 0.0) f = 0.0;
return static_cast<std::int64_t>(f + 0.5);
};
const auto secToFrames = [sr](double sec) { return secondsToFrames(sec, sr); };
// The one seconds->frames fold for a stored AHD; the fraction and the curves are rate-free.
const auto resolveAhd = [&secToFrames](const AhdSeconds& s) {
AhdParams a;
+2 -2
View File
@@ -180,7 +180,7 @@ struct InstrumentParams {
// 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
// engines.
double keyTrack = 1.0;
double keyTrack = kKeyTrackDefault;
// 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):
@@ -215,7 +215,7 @@ struct InstrumentParams {
struct ResolvedCapture {
std::string relativePath; // project-relative; the shell resolves + decodes it
int rootNote = 60; // effective: override, else bank intrinsic, else 60
double keyTrack = 1.0;
double keyTrack = kKeyTrackDefault;
VelocityCurve velocityCurve = VelocityCurve::flat();
SampleLoop loop; // effective: loopOverride, else bank intrinsic
std::int64_t loopCrossfadeFrames = 0; // instrument-owned; no bank intrinsic to beat
+41 -13
View File
@@ -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
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
### The id table is FOREVER-FROZEN
@@ -57,6 +63,13 @@ no longer exist.
- `param_units` — `UnitKind`, `unitStringFor`, `plainRangeFor`, the `toPlain` / `toNormalized`
pair, and the defaults read off a default-constructed `PlaySeconds`.
- `param_format` — the eight formatters and the digits parser behind `getParamValueByString`.
- `param_live` — the AUDIO-THREAD half: one exposed control patched into the live block in
place, allocation-free and lock-free, for the host's `IParameterChanges` queue. It exists
because the model layer cannot run there (`PlaySeconds` carries velocity curves and spline
contours, so `resolvePlay` allocates) while the queue is delivered there. Every law is called —
`ui::storedFromNorm` and `map::secondsToFrames` are the same two the model path uses; what is
new is the ROUTING, and that is pinned by an exhaustive equivalence test against the model path
over every exposed control rather than by two tables that happen to agree.
## Gotchas
@@ -68,18 +81,33 @@ no longer exist.
- **Round-trip exactness at arbitrary values is NOT a property here and must not be asserted.**
No log map satisfies `toNormalized(toPlain(n)) == n` in double, and demanding it would rule
out the taper the range needs. Exactness is required at the defaults; monotonicity everywhere.
- **A curve exponent inside the knob detent but not exactly neutral reads `1.00` to the host.**
The detent lives in `curve_law`'s norm↔exponent map and the host's only handle is the norm, so
the host cannot see an off-detent near-neutral exponent — reachable only by an overlay knot
drag, which writes through `curveFromLevelAt` rather than the knob law. The editor's own label
deliberately reads the stored field directly and still shows the true value; that divergence is
structural to VST3, not a formatter defect.
- **A curve exponent inside the knob detent but not exactly neutral is NEUTRALIZED by any host
touch — a VALUE consequence, not a display one.** The detent lives in `curve_law`'s
norm↔exponent map and the host's only handle is the norm, so the host reads such an exponent
back as `1.00` (the editor's own label reads the stored field and still shows the true value).
The sharp half is the WRITE: a host write of that norm reaches `ui::setDeckParam` →
`storedFromNorm` → `util::curveFromKnobNorm`, whose ±0.01 detent rewrites the stored exponent
to exactly `1.0`. So an off-detent near-neutral exponent set by an overlay knot drag is
silently flattened by any host touch or lane pass over that parameter.
**Assessed and ACCEPTED, not merely documented:** the alternative is to widen the exposed
parameter's law so the detent band is addressable, and §6.3 freezes that law on the first
shipped build — a permanent change to twelve parameters' normalization, to preserve a
difference the user cannot see on the knob (the detent exists precisely because a drag cannot
land on the identity reliably) and cannot hear (the band is ±0.047 of the exponent). Removing
the detent from the WRITE path alone would leave the knob unable to reach the identity, which
is the defect it was added for. The residual is confined to knot-drawn near-neutral curves.
- **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.
- **The filter's four store their position as a `float`, so a not-yet-stored norm can display
one digit differently.** A host previewing a value it has sent but that has not round-tripped
through the stored float differs from the editor by up to a float ulp; at a value landing
exactly on a display rounding boundary that is worth one integer percent on morph. Both
surfaces read the MODEL in every settled state, so this is a transient of the write itself,
not a standing divergence — `test_param_format` holds those four to the plain value rather
than to the string for exactly this reason.
- **MORPH ALONE can display one digit differently from a not-yet-stored norm.** The filter's four
store their position as a `float`, but cutoff, Q and drive cast the incoming norm to `float`
*inside* `toPlain`, so `toPlain(n)` and `toPlain(double(float(n)))` are bit-identical and those
three are held to digit-for-digit string equality like everything else. Morph's path is
full-double (`clamp01(n) * 100`), so the float the model stores and the double the host holds
are genuinely different inputs — worth one integer percent at a value landing on a display
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.
+21 -6
View File
@@ -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
# 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
SOURCES param_units.cpp
LINK PUBLIC deck_values param_taper curve_law master_gain filter)
reasampler_test(param_units LINK param_units param_id)
LINK PUBLIC deck_values param_taper curve_law master_gain filter_params)
# 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)
# param_id is linked for the test only: the one-formatter-two-consumers assertion sweeps the
# exposed set, which is identity's answer rather than this module's.
reasampler_test(param_format LINK param_format param_id)
# param_id and sample_map are linked for the test only: the one-formatter-two-consumers assertion
# sweeps the exposed set (identity's answer, not this module's) and reads pitch key-track where it
# 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)
+105
View File
@@ -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
+29
View File
@@ -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
+59 -4
View File
@@ -66,10 +66,49 @@ UnitKind unitKindFor(DeckParam deck) {
return UnitKind::Decibels;
case DeckParam::kFilterCutoff:
return UnitKind::Hertz;
default:
// The twelve curve exponents, Q and drive. Everything else has no row at all.
// The twelve curve exponents and the filter's two dimensionless tone controls. Listed
// rather than defaulted, and everything with no parameter row at all is listed with
// them: a `default:` here would let a control promoted later inherit Dimensionless
// silently, and §6.3 freezes an exposed parameter's normalization on the first shipped
// build — so the wrong answer would be permanent rather than correctable.
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; // unreachable for a valid enumerator; silences a warning.
}
const char* unitStringFor(DeckParam deck) {
@@ -192,9 +231,18 @@ double toNormalized(DeckParam deck, double plain) {
return plain;
}
bool storesNormalized(DeckParam deck) {
ValueHome valueHomeFor(DeckParam deck) {
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) {
@@ -204,10 +252,17 @@ double defaultPlain(DeckParam deck) {
if (const float* stored = ui::deckFloatField(deck, defaults)) {
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::kKeyTrack) {
return kKeyTrackDefault * kPercentFullScale;
}
const double* field = ui::deckDoubleField(deck, defaults);
if (!field) return 0.0;
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::PercentUnipolar: return *field * kPercentFullScale;
case UnitKind::PercentKeyTrack: return *field * kPercentFullScale; // stored 0..2
+11
View File
@@ -48,6 +48,17 @@ PlainRange plainRangeFor(DeckParam deck);
double toPlain(DeckParam deck, double normalized);
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
// 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
+3 -3
View File
@@ -201,8 +201,10 @@ DeckParam curveParamFor(DeckParam knob) {
LiveCommit deckParamCommit(DeckParam 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::kKeyTrack:
case DeckParam::kTrigLength:
return LiveCommit::NoteOnLatched;
// 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
@@ -256,9 +258,7 @@ LiveCommit deckParamCommit(DeckParam id) {
// to non-live. Reasons live in the header.
case DeckParam::kPlayMode:
case DeckParam::kPitchEngine:
case DeckParam::kTrigLength:
case DeckParam::kPitchEnvEnable:
case DeckParam::kKeyTrack:
case DeckParam::kFilterEnable:
case DeckParam::kAmpVelCurve:
case DeckParam::kPitchVelCurve:
+55 -76
View File
@@ -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) {
switch (id) {
case DeckParam::kPlayMode:
@@ -112,88 +155,24 @@ void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment) {
case DeckParam::kPitchEngine:
play.pitchEngine = (segment == 1) ? PitchEngine::Preserve : PitchEngine::Varispeed;
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::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::kFilterLaw:
play.filter.settings.morphLaw =
(segment == 1) ? MorphLaw::HighNotchLow : MorphLaw::HighBandLow;
break;
case DeckParam::kFilterMorph:
play.filter.settings.morphNorm = static_cast<float>(clamp01(value)); break;
case DeckParam::kFilterCutoff:
play.filter.settings.cutoffNorm = static_cast<float>(clamp01(value)); break;
case DeckParam::kFilterQ:
play.filter.settings.resonanceNorm = static_cast<float>(clamp01(value)); break;
case DeckParam::kFilterDrive:
play.filter.settings.driveNorm = static_cast<float>(clamp01(value)); break;
case DeckParam::kFilterModAmt: play.filter.modAmount = deckBipolarFromNorm(value); break;
case DeckParam::kFilterVel: play.filter.velAmount = deckBipolarFromNorm(value); break;
case DeckParam::kFilterKeyTrack:
play.filter.keyTrack = clamp01(value) * kKeyTrackMax; 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;
default:
// Every knob: the one norm -> stored law, into the one field the control names.
// Both halves are shared with the audio thread's live patch (param/param_live), so
// a control cannot take a different taper or land in a different field depending on
// which surface wrote it. A toggle or a value living outside PlaySeconds resolves to
// neither field and falls through untouched.
if (float* f = deckFloatField(id, play)) {
*f = static_cast<float>(storedFromNorm(id, value));
} else if (double* d = deckDoubleField(id, play)) {
*d = storedFromNorm(id, value);
}
break;
}
// 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
+14
View File
@@ -11,6 +11,7 @@
#include "core/instrument/ui/deck_groups.h" // DeckParam
#include "core/instrument/ui/envelope_overlay.h" // kGateStageMaxSeconds
#include "core/instrument/ui/param_taper.h" // UnitCategory + the shared tapers
#include "core/util/clamp01.h"
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.
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
// 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.
@@ -41,6 +49,12 @@ inline constexpr double kRateMaxRatio = engine::kStretchRateMax;
// shell reads those from the processor.
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).
// Mutates `play` in place, touching exactly the one field the control names.
void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment);