Report the instrument's automatable parameters to the host under a frozen id table, in signal-flow order, with real units

42 of 44 ids issued: pitch key-track and Trigger length stay reserved
pending a live path. Master gain reclassified Live — it never reloaded.
This commit is contained in:
2026-08-02 15:14:16 -04:00
parent c7afa3a80f
commit bfaa0f2614
38 changed files with 1742 additions and 218 deletions
+10 -5
View File
@@ -17,6 +17,11 @@ subdirectories:
- **`bake/`** — the resample bake's pure half: the programmed note resolved to a frame
window, the offline render over a voice engine built for that render alone, and the
ratified post-bake reset. See `bake/CLAUDE.md`.
- **`param/`** — what the instrument tells a VST3 host about its automatable parameters,
with no VST3 type in it: the FOREVER-FROZEN id table, the exposed set derived from
`deckParamCommit`, the plain-value layer, and the one formatter per unit category. Sits
ABOVE `ui/` — the list is a function of the commit predicate, never the reverse. See
`param/CLAUDE.md`.
- **`ui/`** — pure editor geometry/hit-test modules (the band-stack allocator and its band
interiors, waveform, keyboard strip, capture browser, param controls, envelope
overlay/edit). These are geometry-and-math only; the LICE draw + REAPER/VST3 plumbing is
@@ -309,7 +314,7 @@ anything for a trigger shape.
never buy itself a higher lowest-findable fundamental.
- `time_stretch` — the TIME half beside `pitch_shift`'s PITCH half, header-only: `StretchCursor`, the per-output-frame source-feed schedule (a fractional cursor carrying its rate debt, loop-wrapped), plus the rate bounds and their clamp. Rate 1.0 is exactly one source frame per output frame with no residue, which is what makes the unity Preserve read bit-identical to the pre-stretch engine. The bounds are **measured**, not arbitrary — see the header.
- `velocity_curve` — THE monotone spline, shared by every consumer: the three velocity transfer curves and the three spline EGs. `VelocityCurve` is evaluated as ONE OR MORE Fritsch–Carlson monotone cubic Hermite splines joined at its HARD points — a hard knot is a sub-curve boundary for tangent purposes (exactly what the point array's own ends already are), so the two adjacent segments meet at their natural angle instead of a shared derivative and the no-overshoot guarantee holds PER SEGMENT rather than globally. Points are smooth by default; the ceiling is `kMaxCurvePoints` = 128, a MUSICAL bound (long rhythmic phrases, ~two points per articulation event) and not a performance one — **do not lower it**. `eval(velocity)` is the COLD reader, called once per note-on or once per drawn pixel column; `SplineCursor` is the RT one, an indexed segment search plus one Hermite evaluation with the segment and its tangents cached across samples. Both share the same `segmentTangents`/`hermiteAt` free functions, so there is one spline and not two. It carries its own y `CurveDomain`: UNIPOLAR [0,1] is the amp's GAIN, defaulting to `flat()` (y=1, every velocity→unity — a deliberate non-back-compat replacement of the old fixed `velocity/127` path, Daniel-approved); BIPOLAR [−1,1] is the signed modulation shape for pitch and filter, defaulting to `zero()` so velocity modulates neither until a curve is drawn. A bipolar curve does not imply the absence of a depth beside it: the filter keeps its `velAmount` knob and the two compose multiplicatively (`velAmount × curve.eval(v)`, `play_params.h`), while the pitch curve's throw is the fixed `kVelocityPitchRangeSemitones`.
- `master_gain` — pure dB↔linear taper math (FB1): normalized [0,1] ↔ dB ↔ linear for the post-mixer master gain control (−∞…+24 dB, norm 0 = true silence, unity ≈ 0.714). Shared by the editor knob and the processor multiply so the needle, persisted value, and audio multiply cannot drift.
- `master_gain` — pure dB↔linear taper math (FB1): normalized [0,1] ↔ dB ↔ linear for the post-mixer master gain control (−∞…+24 dB, norm 0 = true silence, unity ≈ 0.714). Shared by the editor knob, the processor multiply and the host's `toPlain` so the needle, persisted value, audio multiply and reported dB cannot drift. Math only — the dB label is `param/param_format`'s, so the editor and the host cannot print it two ways.
- `limiter` — the master bus's lookahead brickwall limiter, the stage after `master_gain`'s multiply: a 4x-oversampled TRUE-PEAK detector in the SIDECHAIN ONLY (the signal path is never oversampled), one stereo-linked gain, a baked −0.3 dBTP ceiling and **no makeup gain of any kind**. The gain law is a sliding MINIMUM of the per-sample target over the lookahead window followed by a MOVING AVERAGE of the same width: every term of that average is a minimum whose own window contains the sample being gained, so the ceiling is held **structurally** rather than by a tuned attack, and the one-pole release only ever slows the RISE so that bound survives it. Bypassed and settled, `process()` returns without reading or writing a sample — the byte-identical at-rest path, on the same discipline as `live == nullptr` and the filter's exact skip at `modAmount == 0`. `prepare()` owns every allocation and every transcendental. **Switching is a MUTE, never a blend:** unlimited signal is emitted at weight 1 (the untouched bypass buffer) or at weight 0 and never in between, because a fraction of an unlimited signal is a peak over the ceiling — so the fade always rides the limited path and the hard edge always lands on the bypassed side, against silence. Do not reintroduce an equal-gain dry/wet crossfade over the toggle.
- `meter_ballistics` — the output meter's UI-side ballistics and dB scale: instantaneous rise, 20 dB/s fall, the 1.5 s peak hold and its release at the same rate, the clip latch, and the dB → normalized map over −60…+6 dBFS. The audio thread publishes raw block peaks and converts nothing; this module is what turns them into what the bar draws. Per-channel and stage-agnostic — the MASTER column's own state (both channels plus the gain-reduction lamp) composes it in `ui/master_meter`.
@@ -349,10 +354,10 @@ anything for a trigger shape.
value is COPIED rather than round-tripped: that taper bypass is mandatory and must never be
"simplified" back into a norm round trip), `deckParamUnit`/`snapDeckParamNorm` (THE snap-unit
table, and where each control's full scale enters — a whole DISPLAYED percent is a different
norm step at 0..100 %, 0..200 % and ±100 %), and `formatEnvTimeMs`, the
ONE time-constant formatter: every displayed time constant reads in **ms**, never seconds, so
two stage times are comparable at a glance. A display-unit decision only — nothing about the
stored representation changes. Links the header-only `play_seconds`, deliberately not
norm step at 0..100 %, 0..200 % and ±100 %). Display FORMATTING is not here — `param/`'s
`param_format` owns the one formatter per unit category, because the host and the editor must
be its two callers and neither may hold a second implementation. Links the header-only
`play_seconds`, deliberately not
`sample_map`: `PlaySeconds` is the whole of what a deck edits, and linking the mapping would
drag the bank model and the WAV codec in behind it. The shell keeps only the controls the
parameter set does not carry (key-track, voice count, master gain, preview velocity) and the
+2
View File
@@ -2,6 +2,8 @@ add_subdirectory(engine)
add_subdirectory(map)
add_subdirectory(note)
add_subdirectory(ui)
# After ui: the VST3 parameter identity reads the deck's commit predicate and its value binding.
add_subdirectory(param)
# Last: bake composes the three above it.
add_subdirectory(bake)
+10
View File
@@ -54,6 +54,16 @@ decision about what the render made obsolete.
- **`kStageTimeMaxSeconds` (the stage-time ceiling `param_taper` owns) is not a reset-list
candidate at all** — it bounds a knob's taper, is never itself a dialed value, and so has
no disposition to classify against the ratified reset rule.
- **A host automation lane outranks the reset, and the bake cannot clear it — a NAMED
limitation, not a bug.** Every reset-class value that is also an exposed VST3 parameter is
now notified to the host (the reset writes through `setInstrumentParams`, which is the one
notification funnel), so the host's DISPLAY follows the reset. A lane, however, lives in the
host's project data: if a reset-class parameter carries one, the host replays its curve onto
audio that already has that processing baked in — double processing, and the "sounds as the
dialled instrument sounded just before the click" claim does not hold in that case. There is
no detection available: `IAutomationState` reports the host's automation mode for the whole
plug-in, not per parameter, so both "refuse the bake" and "reset only the un-automated ones"
are unbuildable rather than merely unattractive. The user's remedy is to remove the lane.
## Modules
@@ -52,6 +52,13 @@ float filterDriveDepthFromNorm(float norm) {
return static_cast<float>(kFilterDriveDepthMax * n * n);
}
float filterNormFromDriveDepth(float depth) {
if (!(depth > 0.0f)) return 0.0f; // also catches NaN
if (depth >= kFilterDriveDepthMax) return 1.0f;
return static_cast<float>(
std::sqrt(static_cast<double>(depth) / static_cast<double>(kFilterDriveDepthMax)));
}
float filterNormFromQ(float q) {
if (!(q > kFilterQMin)) return 0.0f;
if (q >= kFilterQMax) return 1.0f;
@@ -49,4 +49,9 @@ float filterNormFromQ(float q);
// linear rather than merely close.
float filterDriveDepthFromNorm(float norm);
// Exact inverse of filterDriveDepthFromNorm; out-of-range depth clamps to 0 or 1. The analytic
// inverse of a frozen law is not a change to it — it has the standing the two inverses above
// already have.
float filterNormFromDriveDepth(float depth);
} // namespace reasampler::instrument::engine::filter
@@ -6,7 +6,6 @@
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <limits>
namespace reasampler::instrument::engine {
@@ -37,15 +36,4 @@ double masterGainNormFromLinear(double linear) {
return masterGainNormFromDb(20.0 * std::log10(linear));
}
void formatMasterGainLabel(double norm, char* buf, std::size_t len) {
if (!buf || len == 0) return;
norm = clamp01(norm);
if (norm <= 0.0) {
std::snprintf(buf, len, "-inf");
return;
}
const double db = masterGainDbFromNorm(norm);
std::snprintf(buf, len, "%+.1fdB", db);
}
} // namespace reasampler::instrument::engine
-6
View File
@@ -7,8 +7,6 @@
#pragma once
#include <cstddef>
namespace reasampler::instrument::engine {
// norm 0 is -inf (true zero); norm just above 0 starts at the finite floor kMasterGainMinDb
@@ -31,8 +29,4 @@ double masterGainLinearFromNorm(double norm);
// true-zero and the floor aren't representable on the knob. Out-of-range/non-finite clamps.
double masterGainNormFromLinear(double linear);
// "-inf" at the bottom, else a signed one-decimal dB string ("-12.0dB", "+2.4dB").
// Writes at most `len` bytes including the terminator.
void formatMasterGainLabel(double norm, char* buf, std::size_t len);
} // namespace reasampler::instrument::engine
+85
View File
@@ -0,0 +1,85 @@
# src/core/instrument/param — the VST3 parameter surface's pure half
## Scope
What the instrument tells a VST3 host about its automatable parameters, with no VST3 type
anywhere: the frozen id table, the exposed set derived from the deck's commit predicate, the
plain-value layer (unit category, plain range, `toPlain` / `toNormalized`), and the one
formatter per unit category. The VST3 shell (`shell/instrument/instrument_params`) adapts
these onto `Steinberg::Vst::Parameter`; it decides nothing.
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.
## Invariants
### The id table is FOREVER-FROZEN
`param_id.h`'s header states the rule in full and is its one home. It sits on the same footing
as the extension's `"STABLE_FOREVER_STRING"` command ids, the two VST3 class UIDs
(`core/wire/reasampler_uid.h`) and the params-payload field order
(`map/component_state_io.h`) — the fourth member of that family, not a new kind of rule.
**The table carries every assigned number, including numbers not issued today.** Membership of
the parameter list is `isExposed`'s answer, not the table's. A row whose control is currently
`Reload`-tier keeps its number reserved: the day that control gains a live path it is exposed
under the number already written beside it, and no other id moves. That is what the
block-and-step scheme buys, and it is why a refusal to promote a control is cheap.
### The list follows the predicate; the predicate is never bent to fill the list
A control is an exposed parameter **iff** `deckParamCommit` classifies it `Live` or
`NoteOnLatched`. There is no second membership table and no per-control exception. Adding a
parameter means giving a control a live path in `deck_groups`, at which point it qualifies by
the same rule that excluded it.
### `toPlain` is a READ-side mapping and changes no stored value
Reporting Hz / Q / drive depth for the filter's four means **calling** `filter_params`' frozen
laws, never replacing them: those four persist as normalized doubles in payload v9, so their
laws are already wire-frozen. The same holds for `master_gain`'s dB sweep and `curve_law`'s
exponent travel. Every law here is called; none is restated.
### ONE formatter per unit category, two callers
`param_format` returns the DIGITS of a plain value. The editor's knob label renders those digits
plus its own static chrome (the unit suffix, a curve dial's `^`); the host receives the same
digits from `getParamStringByValue` and the unit string from `ParameterInfo::units`. There is no
second implementation on either side — that is why `formatEnvTimeMs` and `formatMasterGainLabel`
no longer exist.
## Modules
- `param_id` — the frozen `ParamId` constants, the `ParamRow` table (id, `DeckParam`, `IUnitInfo`
unit, title, shortTitle) in ascending id, `isExposed`, and the derived `exposedParams()`.
Ascending id IS the presentation order, so identity order and presentation order agree by
construction rather than by maintenance.
- `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`.
## Gotchas
- **`defaultNormalized` is COMPUTED, never a literal.** It is `toNormalized(defaultPlain)` for
every tapered control, so a host's reset-to-default and the editor's double-click land on the
same value. The filter's four are the one exception and for the opposite reason: their stored
value already IS the normalized one, so their default normalized value is that double verbatim
and no taper participates in the reset path at all.
- **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.
- **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.
+17
View File
@@ -0,0 +1,17 @@
# The frozen id table and the derived exposed set. Links deck_groups alone: the exposed set IS
# deckParamCommit's answer, and identity needs nothing else.
reasampler_pure_library(param_id SOURCES param_id.cpp LINK PUBLIC deck_groups)
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.
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)
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)
@@ -0,0 +1,76 @@
// param_format.cpp — see param_format.h.
#include "core/instrument/param/param_format.h"
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <limits>
namespace reasampler::instrument::param {
void formatPlain(UnitKind kind, double plain, char* buf, std::size_t len) {
if (!buf || len == 0) return;
switch (kind) {
case UnitKind::Time:
// Never switches to seconds, so the ceiling reads 10000 and not 10 — units is one
// static string per parameter and cannot change with magnitude. Sub-10 ms keeps a
// decimal so a short attack is not rounded to a bare "0".
std::snprintf(buf, len, plain < 10.0 ? "%.1f" : "%.0f", plain);
return;
case UnitKind::Semitones:
std::snprintf(buf, len, "%+.1f", plain);
return;
case UnitKind::PercentUnipolar:
case UnitKind::PercentKeyTrack:
std::snprintf(buf, len, "%.0f", plain);
return;
case UnitKind::PercentBipolar:
std::snprintf(buf, len, "%+.0f", plain);
return;
case UnitKind::PercentRate:
// One decimal, not integer percent: the snap grid is whole semitones and those do
// not land on integer percent (+1 st = 105.946 %), so an integer display would print
// a snapped position as a value the snap cannot produce.
std::snprintf(buf, len, "%.1f", plain);
return;
case UnitKind::Decibels:
if (!std::isfinite(plain)) { std::snprintf(buf, len, "-inf"); return; }
std::snprintf(buf, len, "%+.1f", plain);
return;
case UnitKind::Hertz:
// The "k" abbreviation is RETIRED: units is one static string per parameter, so a
// magnitude-switching unit is not expressible, and keeping "12.8k" in the editor
// alone would be exactly the host/editor divergence one formatter exists to forbid.
std::snprintf(buf, len, "%.0f", plain);
return;
case UnitKind::Dimensionless:
std::snprintf(buf, len, "%.2f", plain);
return;
}
buf[0] = '\0';
}
void formatPlainFor(DeckParam deck, double plain, char* buf, std::size_t len) {
formatPlain(unitKindFor(deck), plain, buf, len);
}
bool parsePlain(UnitKind kind, const char* text, double& plain) {
if (!text) return false;
if (kind == UnitKind::Decibels) {
// The one non-numeric string any formatter emits, so the one the parser must recognise.
for (const char* p = text; *p; ++p) {
if (*p == 'i' && p[1] == 'n' && p[2] == 'f') {
plain = -std::numeric_limits<double>::infinity();
return true;
}
}
}
char* end = nullptr;
const double value = std::strtod(text, &end);
if (end == text) return false;
plain = value;
return true;
}
} // namespace reasampler::instrument::param
+26
View File
@@ -0,0 +1,26 @@
// param_format.h — ONE formatter per unit category, and the editor and the host are both its
// callers. It returns the DIGITS of a plain value: no embedded unit, no magnitude-switched unit,
// no width-conditional abbreviation. The editor's knob label adds its own static chrome (the
// unit suffix, a curve dial's "^"); the host receives these digits from getParamStringByValue and
// the unit from ParameterInfo::units. There is no second implementation on either side — the two
// surfaces disagreeing about what a value reads as is a defect class this closes structurally.
#pragma once
#include <cstddef>
#include "core/instrument/param/param_units.h"
namespace reasampler::instrument::param {
// Writes at most `len` bytes including the terminator.
void formatPlain(UnitKind kind, double plain, char* buf, std::size_t len);
// The digits a control reads as at `plain`. Convenience over formatPlain for the common case.
void formatPlainFor(DeckParam deck, double plain, char* buf, std::size_t len);
// Digits -> plain, for getParamValueByString. False when the text carries no number; a trailing
// unit suffix is tolerated, since a user retyping a displayed value keeps it.
bool parsePlain(UnitKind kind, const char* text, double& plain);
} // namespace reasampler::instrument::param
+105
View File
@@ -0,0 +1,105 @@
// param_id.cpp — see param_id.h. The table is written out rather than derived: a derived id is
// a function of something else, and every such input then has to never change. A literal table
// makes the freeze visible AT THE POINT OF CHANGE — it cannot be renumbered by accident, because
// renumbering it means editing the numbers.
#include "core/instrument/param/param_id.h"
namespace reasampler::instrument::param {
namespace {
// Within a block the order is the group's own semantic order — envelope stages in temporal
// order, filter cells in solve order — seeded ONCE here and never re-seeded from cellIds. A
// group's cell order is exactly as mobile as the deck's row order is; this sequence is a
// property of THIS table, which is what lets DeckParam keep its "runtime-only, free to change"
// licence.
const std::vector<ParamRow>& table() {
static const std::vector<ParamRow> kTable = {
{kParamKeyTrackPitch, DeckParam::kKeyTrack, kUnitPitch, "Key Track", "KeyTrk"},
{kParamRate, DeckParam::kRate, kUnitPitch, "Playback Rate", "Rate"},
{kParamPitchOffset, DeckParam::kPitch, kUnitPitch, "Pitch Offset", "Pitch"},
{kParamPitchEnvAttack, DeckParam::kPitchEnvAttack, kUnitPitchEnv, "Pitch Env Attack", "PEnvA"},
{kParamPitchEnvAttackCurve, DeckParam::kPitchEnvAttackCurve, kUnitPitchEnv, "Pitch Env Attack Curve", "PEnvAC"},
{kParamPitchEnvHold, DeckParam::kPitchEnvHold, kUnitPitchEnv, "Pitch Env Hold", "PEnvH"},
{kParamPitchEnvDecay, DeckParam::kPitchEnvDecay, kUnitPitchEnv, "Pitch Env Decay", "PEnvD"},
{kParamPitchEnvDecayCurve, DeckParam::kPitchEnvDecayCurve, kUnitPitchEnv, "Pitch Env Decay Curve", "PEnvDC"},
{kParamPitchEnvDepth, DeckParam::kPitchEnvDepth, kUnitPitchEnv, "Pitch Env Depth", "PEnvDp"},
{kParamFilterMorph, DeckParam::kFilterMorph, kUnitFilter, "Filter Morph", "Morph"},
{kParamFilterCutoff, DeckParam::kFilterCutoff, kUnitFilter, "Filter Cutoff", "Cutoff"},
{kParamFilterQ, DeckParam::kFilterQ, kUnitFilter, "Filter Q", "Q"},
{kParamFilterDrive, DeckParam::kFilterDrive, kUnitFilter, "Filter Drive", "Drive"},
{kParamFilterModAmount, DeckParam::kFilterModAmt, kUnitFilter, "Filter Env Amount", "FEnvAmt"},
{kParamFilterVelAmount, DeckParam::kFilterVel, kUnitFilter, "Filter Vel Amount", "FVelAmt"},
{kParamKeyTrackFilter, DeckParam::kFilterKeyTrack, kUnitFilter, "Filter Key Track", "FKeyTrk"},
{kParamFilterEnvAttack, DeckParam::kFilterEnvAttack, kUnitFilterEnv, "Filter Env Attack", "FEnvA"},
{kParamFilterEnvAttackCurve, DeckParam::kFilterEnvAttackCurve, kUnitFilterEnv, "Filter Env Attack Curve", "FEnvAC"},
{kParamFilterEnvHold, DeckParam::kFilterEnvHold, kUnitFilterEnv, "Filter Env Hold", "FEnvH"},
{kParamFilterEnvDecay, DeckParam::kFilterEnvDecay, kUnitFilterEnv, "Filter Env Decay", "FEnvD"},
{kParamFilterEnvDecayCurve, DeckParam::kFilterEnvDecayCurve, kUnitFilterEnv, "Filter Env Decay Curve", "FEnvDC"},
{kParamFilterEnvSustain, DeckParam::kFilterEnvSustain, kUnitFilterEnv, "Filter Env Sustain", "FEnvS"},
{kParamFilterEnvRelease, DeckParam::kFilterEnvRelease, kUnitFilterEnv, "Filter Env Release", "FEnvR"},
{kParamFilterEnvReleaseCurve, DeckParam::kFilterEnvReleaseCurve, kUnitFilterEnv, "Filter Env Release Curve", "FEnvRC"},
{kParamFilterTrigAttack, DeckParam::kFilterTrigAttack, kUnitFilterEnv, "Filter Trig Attack", "FTrgA"},
{kParamFilterTrigAttackCurve, DeckParam::kFilterTrigAttackCurve, kUnitFilterEnv, "Filter Trig Attack Curve", "FTrgAC"},
{kParamFilterTrigHold, DeckParam::kFilterTrigHold, kUnitFilterEnv, "Filter Trig Hold", "FTrgH"},
{kParamFilterTrigDecay, DeckParam::kFilterTrigDecay, kUnitFilterEnv, "Filter Trig Decay", "FTrgD"},
{kParamFilterTrigDecayCurve, DeckParam::kFilterTrigDecayCurve, kUnitFilterEnv, "Filter Trig Decay Curve", "FTrgDC"},
{kParamAmpAttack, DeckParam::kAttack, kUnitAmp, "Amp Attack", "AmpA"},
{kParamAmpAttackCurve, DeckParam::kAttackCurve, kUnitAmp, "Amp Attack Curve", "AmpAC"},
{kParamAmpHold, DeckParam::kHold, kUnitAmp, "Amp Hold", "AmpH"},
{kParamAmpDecay, DeckParam::kDecay, kUnitAmp, "Amp Decay", "AmpD"},
{kParamAmpDecayCurve, DeckParam::kDecayCurve, kUnitAmp, "Amp Decay Curve", "AmpDC"},
{kParamAmpSustain, DeckParam::kSustain, kUnitAmp, "Amp Sustain", "AmpS"},
{kParamAmpRelease, DeckParam::kRelease, kUnitAmp, "Amp Release", "AmpR"},
{kParamAmpReleaseCurve, DeckParam::kReleaseCurve, kUnitAmp, "Amp Release Curve", "AmpRC"},
{kParamTriggerLength, DeckParam::kTrigLength, kUnitAmp, "Trigger Length", "TrgLen"},
{kParamAmpTrigAttack, DeckParam::kTrigAttack, kUnitAmp, "Amp Trig Attack", "ATrgA"},
{kParamAmpTrigAttackCurve, DeckParam::kTrigAttackCurve, kUnitAmp, "Amp Trig Attack Curve", "ATrgAC"},
{kParamAmpTrigHold, DeckParam::kTrigHold, kUnitAmp, "Amp Trig Hold", "ATrgH"},
{kParamAmpTrigDecay, DeckParam::kTrigDecay, kUnitAmp, "Amp Trig Decay", "ATrgD"},
{kParamAmpTrigDecayCurve, DeckParam::kTrigDecayCurve, kUnitAmp, "Amp Trig Decay Curve", "ATrgDC"},
{kParamMasterGain, DeckParam::kMasterGain, kUnitMaster, "Master Gain", "Gain"},
};
return kTable;
}
} // namespace
const std::vector<ParamRow>& paramTable() { return table(); }
bool isExposed(DeckParam deck) {
return ui::deckParamCommit(deck) != ui::LiveCommit::Reload;
}
const std::vector<ParamRow>& exposedParams() {
static const std::vector<ParamRow> kExposed = [] {
std::vector<ParamRow> rows;
for (const ParamRow& row : table()) {
if (isExposed(row.deck)) rows.push_back(row);
}
return rows;
}();
return kExposed;
}
const ParamRow* exposedRowFor(ParamId id) {
for (const ParamRow& row : exposedParams()) {
if (row.id == id) return &row;
}
return nullptr;
}
ParamId paramIdFor(DeckParam deck) {
for (const ParamRow& row : table()) {
if (row.deck == deck) return row.id;
}
return 0;
}
} // namespace reasampler::instrument::param
+135
View File
@@ -0,0 +1,135 @@
// param_id.h — the VST3 parameter identity space: the frozen id table, its DeckParam binding,
// the deck-group units, and the exposed set DERIVED from the commit predicate. Pure: no VST3
// type appears here, so the whole contract is provable without a host.
#pragma once
#include <cstdint>
#include <vector>
#include "core/instrument/ui/deck_groups.h" // DeckParam + deckParamCommit (the predicate)
namespace reasampler::instrument::param {
using ui::DeckParam;
// A host records this number into automation lanes inside project files this repo does not own
// and cannot migrate.
//
// THE PARAMETER-ID TABLE IS FOREVER-FROZEN, on the same footing as the extension's
// "STABLE_FOREVER_STRING" command ids, the two VST3 class UIDs (core/wire/reasampler_uid.h) and
// the params-payload field order (map/component_state_io.h):
// - No id is ever reassigned, reused or re-pointed. A control whose meaning genuinely changes
// takes a NEW id; the old one is marked dead here and never re-issued.
// - No exposed parameter's normalization ever changes — not its taper, not either range
// endpoint, not its stepCount. The normalization IS the meaning of every recorded point.
// - A parameter's meaning never depends on a mode. The Gate-face and Trigger-face stage times
// are separate stored fields and take separate ids.
// - A new control takes the next free slot inside its own group's block, never the next number
// at the end of the table.
// Display strings, titles and precision are NOT frozen — they are what a user reads, not what a
// lane stores.
using ParamId = std::uint32_t;
// Blocks of 100 per deck group in SIGNAL-FLOW order, steps of 10 within a block, a curve dial at
// its outer knob's id + 1. Blocks start at 1000 so the first legitimate id is not also the most
// likely bug value (a default-initialised ParamId). Nine free slots between neighbours put a
// control added later numerically beside its siblings instead of at the end of the table.
// 1500-1599 (VELOCITY) and 1600-1699 (VOICE) are RESERVED and empty — a control either group
// ever gains lands in its own range rather than in whatever range happened to be free.
enum : ParamId {
kParamKeyTrackPitch = 1000,
kParamRate = 1010,
kParamPitchOffset = 1020,
kParamPitchEnvAttack = 1100,
kParamPitchEnvAttackCurve= 1101,
kParamPitchEnvHold = 1110,
kParamPitchEnvDecay = 1120,
kParamPitchEnvDecayCurve = 1121,
kParamPitchEnvDepth = 1130,
kParamFilterMorph = 1200,
kParamFilterCutoff = 1210,
kParamFilterQ = 1220,
kParamFilterDrive = 1230,
kParamFilterModAmount = 1240,
kParamFilterVelAmount = 1250,
kParamKeyTrackFilter = 1260,
kParamFilterEnvAttack = 1300,
kParamFilterEnvAttackCurve = 1301,
kParamFilterEnvHold = 1310,
kParamFilterEnvDecay = 1320,
kParamFilterEnvDecayCurve = 1321,
kParamFilterEnvSustain = 1330,
kParamFilterEnvRelease = 1340,
kParamFilterEnvReleaseCurve = 1341,
kParamFilterTrigAttack = 1350,
kParamFilterTrigAttackCurve = 1351,
kParamFilterTrigHold = 1360,
kParamFilterTrigDecay = 1370,
kParamFilterTrigDecayCurve = 1371,
kParamAmpAttack = 1400,
kParamAmpAttackCurve = 1401,
kParamAmpHold = 1410,
kParamAmpDecay = 1420,
kParamAmpDecayCurve = 1421,
kParamAmpSustain = 1430,
kParamAmpRelease = 1440,
kParamAmpReleaseCurve = 1441,
kParamTriggerLength = 1450,
kParamAmpTrigAttack = 1460,
kParamAmpTrigAttackCurve = 1461,
kParamAmpTrigHold = 1470,
kParamAmpTrigDecay = 1480,
kParamAmpTrigDecayCurve = 1481,
kParamMasterGain = 1700,
};
// IUnitInfo units, one per deck group that carries an exposed parameter. 0 is the SDK's root
// unit, so these start at 1. Softer than the id freeze but user-facing and cached by some hosts.
enum : std::int32_t {
kUnitRoot = 0,
kUnitPitch = 1,
kUnitPitchEnv = 2,
kUnitFilter = 3,
kUnitFilterEnv = 4,
kUnitAmp = 5,
kUnitMaster = 6,
};
struct ParamRow {
ParamId id;
DeckParam deck;
std::int32_t unit;
const char* title; // survives truncation
const char* shortTitle; // distinct, for a narrow host column
};
// The WHOLE frozen assignment, in ascending id — which is also the presentation order, so
// identity order and presentation order agree by construction rather than by maintenance.
// Membership of the parameter list is NOT decided here: a row is issued to the host only if
// isExposed() says so. A row whose control is not exposed today keeps its number reserved for
// the day that control gains a live path, which is what the block-and-step scheme buys.
const std::vector<ParamRow>& paramTable();
// A control is an exposed VST3 parameter IF AND ONLY IF its commit class is Live or
// NoteOnLatched. Derived from deckParamCommit, never hand-maintained: the list follows the
// predicate, and the predicate is never bent to fill the list.
bool isExposed(DeckParam deck);
// paramTable() filtered by isExposed, still in ascending id. This is exactly what the host is
// told, in the order it is told.
const std::vector<ParamRow>& exposedParams();
// The row for an id, or null when the id is unknown or its control is not exposed.
const ParamRow* exposedRowFor(ParamId id);
// The id a control is numbered as, or 0 when the control has no row at all. Answers for
// unexposed rows too — the number is a property of the table, not of today's membership.
ParamId paramIdFor(DeckParam deck);
} // namespace reasampler::instrument::param
+232
View File
@@ -0,0 +1,232 @@
// param_units.cpp — see param_units.h. Every law here is CALLED, never restated: the stage-time
// and semitone tapers are param_taper's, the curve travel is curve_law's, the filter's four are
// filter_params' own frozen laws, and the dB sweep is master_gain's.
#include "core/instrument/param/param_units.h"
#include "core/instrument/engine/filter/filter_params.h"
#include "core/instrument/engine/master_gain.h"
#include "core/instrument/map/play_seconds.h"
#include "core/instrument/ui/deck_values.h"
#include "core/instrument/ui/param_taper.h"
#include "core/util/clamp01.h"
#include "core/util/curve_law.h"
namespace reasampler::instrument::param {
namespace {
using map::PlaySeconds;
using util::clamp01;
// A whole displayed percent is a different plain full scale per category; these are the two
// non-100 ones, named rather than inlined so the range table and the maps cannot disagree.
constexpr double kPercentFullScale = 100.0;
const double kKeyTrackFullScale = kPercentFullScale * ui::kKeyTrackMax; // 0..200 %
} // namespace
UnitKind unitKindFor(DeckParam deck) {
switch (deck) {
case DeckParam::kAttack:
case DeckParam::kHold:
case DeckParam::kDecay:
case DeckParam::kRelease:
case DeckParam::kTrigAttack:
case DeckParam::kTrigDecay:
case DeckParam::kPitchEnvAttack:
case DeckParam::kPitchEnvDecay:
case DeckParam::kFilterEnvAttack:
case DeckParam::kFilterEnvHold:
case DeckParam::kFilterEnvDecay:
case DeckParam::kFilterEnvRelease:
case DeckParam::kFilterTrigAttack:
case DeckParam::kFilterTrigDecay:
return UnitKind::Time;
case DeckParam::kPitch:
case DeckParam::kPitchEnvDepth:
return UnitKind::Semitones;
case DeckParam::kSustain:
case DeckParam::kTrigLength:
case DeckParam::kTrigHold:
case DeckParam::kPitchEnvHold:
case DeckParam::kFilterEnvSustain:
case DeckParam::kFilterTrigHold:
case DeckParam::kFilterMorph:
return UnitKind::PercentUnipolar;
case DeckParam::kKeyTrack:
case DeckParam::kFilterKeyTrack:
return UnitKind::PercentKeyTrack;
case DeckParam::kFilterModAmt:
case DeckParam::kFilterVel:
return UnitKind::PercentBipolar;
case DeckParam::kRate:
return UnitKind::PercentRate;
case DeckParam::kMasterGain:
return UnitKind::Decibels;
case DeckParam::kFilterCutoff:
return UnitKind::Hertz;
default:
// The twelve curve exponents, Q and drive. Everything else has no row at all.
return UnitKind::Dimensionless;
}
}
const char* unitStringFor(DeckParam deck) {
switch (unitKindFor(deck)) {
case UnitKind::Time: return "ms";
case UnitKind::Semitones: return "st";
case UnitKind::PercentUnipolar:
case UnitKind::PercentKeyTrack:
case UnitKind::PercentBipolar:
case UnitKind::PercentRate: return "%";
case UnitKind::Decibels: return "dB";
case UnitKind::Hertz: return "Hz";
case UnitKind::Dimensionless: return "";
}
return "";
}
PlainRange plainRangeFor(DeckParam deck) {
switch (deck) {
case DeckParam::kFilterQ:
return {static_cast<double>(engine::filter::kFilterQMin),
static_cast<double>(engine::filter::kFilterQMax)};
case DeckParam::kFilterDrive:
return {0.0, static_cast<double>(engine::filter::kFilterDriveDepthMax)};
default:
break;
}
switch (unitKindFor(deck)) {
case UnitKind::Time: return {0.0, ui::kEnvTimeMaxSeconds * 1000.0};
case UnitKind::Semitones: return {-ui::kPitchDepthMaxSemis, ui::kPitchDepthMaxSemis};
case UnitKind::PercentUnipolar: return {0.0, kPercentFullScale};
case UnitKind::PercentKeyTrack: return {0.0, kKeyTrackFullScale};
case UnitKind::PercentBipolar: return {-kPercentFullScale, kPercentFullScale};
case UnitKind::PercentRate: return {ui::kRateMinRatio * kPercentFullScale,
ui::kRateMaxRatio * kPercentFullScale};
case UnitKind::Decibels: return {engine::kMasterGainMinDb, engine::kMasterGainMaxDb};
case UnitKind::Hertz: return {static_cast<double>(engine::filter::kFilterCutoffMinHz),
static_cast<double>(engine::filter::kFilterCutoffMaxHz)};
case UnitKind::Dimensionless: return {util::kCurveMin, util::kCurveMax};
}
return {};
}
double toPlain(DeckParam deck, double normalized) {
switch (deck) {
case DeckParam::kFilterCutoff:
return static_cast<double>(
engine::filter::filterCutoffHzFromNorm(static_cast<float>(normalized)));
case DeckParam::kFilterQ:
return static_cast<double>(
engine::filter::filterQFromNorm(static_cast<float>(normalized)));
case DeckParam::kFilterDrive:
return static_cast<double>(
engine::filter::filterDriveDepthFromNorm(static_cast<float>(normalized)));
case DeckParam::kMasterGain:
// -inf at norm 0 — true silence, and the one plain value outside the declared range.
return engine::masterGainDbFromNorm(normalized);
default:
break;
}
switch (unitKindFor(deck)) {
case UnitKind::Time:
return ui::timeSecondsFromNorm(normalized) * 1000.0;
case UnitKind::Semitones:
return ui::depthSemitonesFromNorm(normalized, ui::kPitchDepthMaxSemis);
case UnitKind::PercentUnipolar:
return clamp01(normalized) * kPercentFullScale;
case UnitKind::PercentKeyTrack:
return clamp01(normalized) * kKeyTrackFullScale;
case UnitKind::PercentBipolar:
return ui::deckBipolarFromNorm(normalized) * kPercentFullScale;
case UnitKind::PercentRate:
return ui::rateRatioFromNorm(normalized, ui::kRateMinRatio, ui::kRateMaxRatio) *
kPercentFullScale;
case UnitKind::Dimensionless:
return util::curveFromKnobNorm(normalized);
case UnitKind::Decibels:
case UnitKind::Hertz:
break; // handled above
}
return normalized;
}
double toNormalized(DeckParam deck, double plain) {
switch (deck) {
case DeckParam::kFilterCutoff:
return static_cast<double>(
engine::filter::filterNormFromCutoffHz(static_cast<float>(plain)));
case DeckParam::kFilterQ:
return static_cast<double>(
engine::filter::filterNormFromQ(static_cast<float>(plain)));
case DeckParam::kFilterDrive:
return static_cast<double>(
engine::filter::filterNormFromDriveDepth(static_cast<float>(plain)));
case DeckParam::kMasterGain:
return engine::masterGainNormFromDb(plain);
default:
break;
}
switch (unitKindFor(deck)) {
case UnitKind::Time:
return ui::timeNormFromSeconds(plain / 1000.0);
case UnitKind::Semitones:
return ui::depthNormFromSemitones(plain, ui::kPitchDepthMaxSemis);
case UnitKind::PercentUnipolar:
return clamp01(plain / kPercentFullScale);
case UnitKind::PercentKeyTrack:
return clamp01(plain / kKeyTrackFullScale);
case UnitKind::PercentBipolar:
return ui::deckNormFromBipolar(plain / kPercentFullScale);
case UnitKind::PercentRate:
return ui::rateNormFromRatio(plain / kPercentFullScale, ui::kRateMinRatio,
ui::kRateMaxRatio);
case UnitKind::Dimensionless:
return util::knobNormFromCurve(plain);
case UnitKind::Decibels:
case UnitKind::Hertz:
break; // handled above
}
return plain;
}
bool storesNormalized(DeckParam deck) {
PlaySeconds defaults;
return ui::deckFloatField(deck, defaults) != nullptr;
}
double defaultPlain(DeckParam deck) {
PlaySeconds defaults;
// The filter's four store the normalized position itself, so their plain default is that
// stored position read THROUGH the law — the law is the display, never the storage.
if (const float* stored = ui::deckFloatField(deck, defaults)) {
return toPlain(deck, static_cast<double>(*stored));
}
if (deck == DeckParam::kMasterGain) return 0.0; // unity, and the sharpest exactness case
const double* field = ui::deckDoubleField(deck, defaults);
if (!field) return 0.0;
switch (unitKindFor(deck)) {
case UnitKind::Time: return *field * 1000.0; // stored seconds
case UnitKind::PercentUnipolar: return *field * kPercentFullScale;
case UnitKind::PercentKeyTrack: return *field * kPercentFullScale; // stored 0..2
case UnitKind::PercentBipolar: return *field * kPercentFullScale;
case UnitKind::PercentRate: return *field * kPercentFullScale; // stored ratio
case UnitKind::Semitones:
case UnitKind::Dimensionless: return *field; // already the plain unit
case UnitKind::Decibels:
case UnitKind::Hertz: break; // handled above
}
return *field;
}
double defaultNormalized(DeckParam deck) {
PlaySeconds defaults;
if (const float* stored = ui::deckFloatField(deck, defaults)) {
return static_cast<double>(*stored); // verbatim: no taper on the reset path
}
return toNormalized(deck, defaultPlain(deck));
}
} // namespace reasampler::instrument::param
+64
View File
@@ -0,0 +1,64 @@
// param_units.h — the plain-value layer the host reads a parameter through: the unit category,
// the plain range, and the norm <-> plain pair. `toPlain` IS the taper's forward map and
// `toNormalized` its inverse, so the host's normalization, the knob's needle angle and the
// overlay node's position are the SAME function rather than three that agree today.
#pragma once
#include "core/instrument/ui/deck_groups.h" // DeckParam
namespace reasampler::instrument::param {
using ui::DeckParam;
// The eight DISPLAY categories. A category fixes the units string and the digit precision; the
// norm <-> plain LAW is per control, because three of the dimensionless controls (the curve
// exponents, Q, drive) share a display and share no law.
enum class UnitKind {
Time, // ms, 0..10000
Semitones, // st, -24..+24, always signed
PercentUnipolar, // %, 0..100
PercentKeyTrack, // %, 0..200
PercentBipolar, // %, -100..+100, always signed
PercentRate, // %, 50..200, one decimal
Decibels, // dB, -60..+24, always signed; norm 0 reads -inf
Hertz, // Hz, 20..20000
Dimensionless, // no unit, two decimals
};
struct PlainRange {
double min = 0.0;
double max = 1.0;
};
UnitKind unitKindFor(DeckParam deck);
// The units string ParameterInfo carries — "" for the dimensionless category. Carried SEPARATELY
// from the digits, which is the SDK's own convention (RangeParameter::toString prints the number;
// the Parameter constructor takes units as its own argument).
const char* unitStringFor(DeckParam deck);
PlainRange plainRangeFor(DeckParam deck);
// A straight line drawn in a host automation lane is NOT linear in these plain units, and that is
// deliberate: exponential in ms, linear in octaves on cutoff, linear in dB on master gain, a
// linear pitch glide on rate, and slow-near-zero on the two centre-expanded semitone throws. It
// follows from reporting real units over a musically-shaped taper; the remedy for a user who
// wants a literal-units ramp is the host's own curve tools, never a change to the taper.
double toPlain(DeckParam deck, double normalized);
double toNormalized(DeckParam deck, double plain);
// 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
// replacing them.
bool storesNormalized(DeckParam deck);
// The default, read off a default-constructed PlaySeconds — there is no second table of defaults,
// and no normalized default is ever written as a literal. defaultNormalized is COMPUTED as
// toNormalized(defaultPlain) for every tapered control, which is what makes a host's
// reset-to-default and the editor's double-click land on the same value.
double defaultPlain(DeckParam deck);
double defaultNormalized(DeckParam deck);
} // namespace reasampler::instrument::param
+5 -1
View File
@@ -204,6 +204,11 @@ LiveCommit deckParamCommit(DeckParam id) {
// The one note-on-latched control; the header owns why.
case DeckParam::kRate:
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
// block rather than through it, which is why it carries no LiveValues field; that is a
// question of ROUTE, and this predicate answers TIER.
case DeckParam::kMasterGain:
case DeckParam::kPitch:
case DeckParam::kAttack:
case DeckParam::kHold:
@@ -270,7 +275,6 @@ LiveCommit deckParamCommit(DeckParam id) {
case DeckParam::kVoiceCount:
case DeckParam::kVoiceMode:
case DeckParam::kMonoTrigger:
case DeckParam::kMasterGain:
case DeckParam::kLimiterEnable:
// MASTER's two readouts reach no parameter at all — the same footing as the overlay
// radios above.
+5
View File
@@ -164,6 +164,11 @@ DeckParam curveParamFor(DeckParam knob);
// Both amp shapes are live: the Trigger fade pair that used to reload folded into the AHD and
// inherited its routing, so a Trigger-mode instance now tracks its amplitude knobs too.
//
// kMasterGain is Live and is the one live control that does NOT ride the live block: it is a
// lock-free atomic on the processor which the audio thread applies as a post-sum multiply. The
// tier answers "does an edit reach the audio without a reload", not "which mechanism carries
// it" — classifying it Reload would have said a gain move re-decodes the WAV, which it never did.
//
// kRate is the one NoteOnLatched control, and the reason is a real feature rather than a
// plumbing detail: loop points and contours both scale with rate, and both are note-on folds —
// resolveLoop runs once per note-on and a contour resolves against the note's own span. A live
-7
View File
@@ -4,7 +4,6 @@
#include <algorithm>
#include <cmath>
#include <cstdio>
#include "core/instrument/engine/filter/filter_morph.h" // MorphLaw (the law toggle's value)
#include "core/instrument/engine/master_gain.h" // the dB taper the whole-dB snap reads
@@ -374,10 +373,4 @@ double snapDeckParamNorm(DeckParam id, double norm) {
return norm;
}
void formatEnvTimeMs(double seconds, char* buf, std::size_t len) {
if (!buf || len == 0) return;
const double ms = seconds * 1000.0;
std::snprintf(buf, len, ms < 10.0 ? "%.1f ms" : "%.0f ms", ms);
}
} // namespace reasampler::instrument::ui
+5 -12
View File
@@ -1,13 +1,11 @@
// deck_values.h — the deck's control-id <-> parameter-set BINDING and its display units: the
// normalized 0..1 a knob shows, the write back into the stored seconds/fractions/positions, the
// double-click reset, and the ms time-constant formatter. Split from the editor shell so the
// whole domain map is provable without a host; deck_groups owns WHICH controls exist, this owns
// what each one's value MEANS.
// deck_values.h — the deck's control-id <-> parameter-set BINDING and its snap units: the
// normalized 0..1 a knob shows, the write back into the stored seconds/fractions/positions, and
// the double-click reset. Split from the editor shell so the whole domain map is provable
// without a host; deck_groups owns WHICH controls exist, this owns what each one's value MEANS,
// and param/param_format owns how it READS.
#pragma once
#include <cstddef>
#include "core/instrument/engine/time_stretch.h" // kStretchRateMin/Max (Rate's own range)
#include "core/instrument/map/play_seconds.h" // PlaySeconds (the deck's edit target)
#include "core/instrument/ui/deck_groups.h" // DeckParam
@@ -75,9 +73,4 @@ UnitCategory deckParamUnit(DeckParam id);
// whole displayed percent and therefore take different norm steps.
double snapDeckParamNorm(DeckParam id, double norm);
// A time constant as MILLISECONDS, e.g. "12 ms". Never switches to seconds: the editor reads in
// one unit so two stage times are comparable at a glance. Sub-10 ms keeps one decimal so a short
// attack is not rounded to a bare "0 ms". Writes at most `len` bytes including the terminator.
void formatEnvTimeMs(double seconds, char* buf, std::size_t len);
} // namespace reasampler::instrument::ui