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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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_++);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user