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:
@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
**ReaSampler** is a per-project audio sample-bank capture tool that builds two artifacts: the REAPER extension (`reaper_reasampler`) and **ReaSampler 9000**, a Windows-only VST3 sampler instrument (`reasampler_9000.vst3`, `core/instrument/` + `shell/instrument/`, second CMake target `reasampler_vst`, gated on the vendored `vendor/vst3sdk` submodule slice). The pure-testable-core / REAPER-facing-shell discipline is preserved throughout: `core/` never includes REAPER or VST3 SDK types, `shell/` is where those hosts are actually touched, `app/` is the extension entry point. Every REAPER API name cited in project docs is correct-by-intent; verify argument order, types, and flag values against `vendor/reaper-sdk/sdk/reaper_plugin_functions.h` before use.
|
**ReaSampler** is a per-project audio sample-bank capture tool that builds two artifacts: the REAPER extension (`reaper_reasampler`) and **ReaSampler 9000**, a Windows-only VST3 sampler instrument (`reasampler_9000.vst3`, `core/instrument/` + `shell/instrument/`, second CMake target `reasampler_vst`, gated on the vendored `vendor/vst3sdk` submodule slice). The pure-testable-core / REAPER-facing-shell discipline is preserved throughout: `core/` never includes REAPER or VST3 SDK types, `shell/` is where those hosts are actually touched, `app/` is the extension entry point. Every REAPER API name cited in project docs is correct-by-intent; verify argument order, types, and flag values against `vendor/reaper-sdk/sdk/reaper_plugin_functions.h` before use.
|
||||||
|
|
||||||
Per-module detail — what each file owns, its invariants — lives in the twenty-three per-directory `src/**/CLAUDE.md` files; see the compact map in "Architecture: the load-bearing split" below to find the right one. Landed-phase history lives in `docs/ARCHIVE.md`; current work lives in `docs/COMPLETED.md`, `docs/TODO.md`, and `docs/TODO-1.0.md` — see "Project docs" below.
|
Per-module detail — what each file owns, its invariants — lives in the twenty-four per-directory `src/**/CLAUDE.md` files; see the compact map in "Architecture: the load-bearing split" below to find the right one. Landed-phase history lives in `docs/ARCHIVE.md`; current work lives in `docs/COMPLETED.md`, `docs/TODO.md`, and `docs/TODO-1.0.md` — see "Project docs" below.
|
||||||
|
|
||||||
## Settled decisions
|
## Settled decisions
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ There is no hot-reload. Copy the **Release** build's binary (`build/Release/` on
|
|||||||
|
|
||||||
## Architecture: the load-bearing split
|
## Architecture: the load-bearing split
|
||||||
|
|
||||||
`core/` holds pure, unit-testable logic — no REAPER or VST3 SDK types, each with a corresponding `<module>_tests` target that runs without a DAW. `shell/` holds the REAPER/host-facing shells — where those SDK types are actually touched. `app/` is the extension entry point. Each of the twenty-three directories below carries its own `CLAUDE.md` with the full module list and that area's invariants — open the relevant one for detail; this file states only repo-wide truth.
|
`core/` holds pure, unit-testable logic — no REAPER or VST3 SDK types, each with a corresponding `<module>_tests` target that runs without a DAW. `shell/` holds the REAPER/host-facing shells — where those SDK types are actually touched. `app/` is the extension entry point. Each of the twenty-four directories below carries its own `CLAUDE.md` with the full module list and that area's invariants — open the relevant one for detail; this file states only repo-wide truth.
|
||||||
|
|
||||||
| Directory | Scope |
|
| Directory | Scope |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -95,6 +95,7 @@ There is no hot-reload. Copy the **Release** build's binary (`build/Release/` on
|
|||||||
| `src/core/instrument/bake/` | the resample bake's pure half — the programmed note resolved to a frame window, the offline render over a bake-only voice engine, and the post-bake reset |
|
| `src/core/instrument/bake/` | the resample bake's pure half — the programmed note resolved to a frame window, the offline render over a bake-only voice engine, and the post-bake reset |
|
||||||
| `src/core/instrument/engine/filter/` | pure per-voice resonant TPT/SVF filter (HP→BP→LP / HP→notch→LP morph, drive stage), run by each `Voice` between the pitch and amp stages |
|
| `src/core/instrument/engine/filter/` | pure per-voice resonant TPT/SVF filter (HP→BP→LP / HP→notch→LP morph, drive stage), run by each `Voice` between the pitch and amp stages |
|
||||||
| `src/core/instrument/note/` | the programmed capture-signal model — musical divisions, tempo resolution, anchored offsets |
|
| `src/core/instrument/note/` | the programmed capture-signal model — musical divisions, tempo resolution, anchored offsets |
|
||||||
|
| `src/core/instrument/param/` | the VST3 parameter surface's pure half — the FOREVER-FROZEN id table, the exposed set derived from the commit predicate, the plain-value layer, and the one formatter per unit category |
|
||||||
| `src/core/json/` | the hand-rolled JSON lexical layer |
|
| `src/core/json/` | the hand-rolled JSON lexical layer |
|
||||||
| `src/core/model/` | the pure bank/sample index and its multi-bank container |
|
| `src/core/model/` | the pure bank/sample index and its multi-bank container |
|
||||||
| `src/core/reclaim/` | pure prune orphan computation |
|
| `src/core/reclaim/` | pure prune orphan computation |
|
||||||
@@ -117,7 +118,8 @@ There is no hot-reload. Copy the **Release** build's binary (`build/Release/` on
|
|||||||
The top-level split is by the pure/shell discipline: `core/` never includes REAPER or VST3 SDK
|
The top-level split is by the pure/shell discipline: `core/` never includes REAPER or VST3 SDK
|
||||||
types; `shell/` is where those host types are actually touched — the discriminator is "may this
|
types; `shell/` is where those host types are actually touched — the discriminator is "may this
|
||||||
file touch a host type, REAPER *or* VST3 SDK." Subsystem directories sit beneath `core/` (see the
|
file touch a host type, REAPER *or* VST3 SDK." Subsystem directories sit beneath `core/` (see the
|
||||||
table above); `core/instrument/` further subdivides into `engine/` / `map/` / `note/` / `ui/`. Namespaces
|
table above); `core/instrument/` further subdivides into `bake/` / `engine/` / `map/` / `note/` /
|
||||||
|
`param/` / `ui/`. Namespaces
|
||||||
mirror directories — `reasampler::<subsystem>` for `core/`, house style for `shell/`. `app/` holds
|
mirror directories — `reasampler::<subsystem>` for `core/`, house style for `shell/`. `app/` holds
|
||||||
`main.cpp` only: API-pointer ownership, `ReaperPluginEntry`, and dispatch.
|
`main.cpp` only: API-pointer ownership, `ReaperPluginEntry`, and dispatch.
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ subdirectories:
|
|||||||
- **`bake/`** — the resample bake's pure half: the programmed note resolved to a frame
|
- **`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
|
window, the offline render over a voice engine built for that render alone, and the
|
||||||
ratified post-bake reset. See `bake/CLAUDE.md`.
|
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
|
- **`ui/`** — pure editor geometry/hit-test modules (the band-stack allocator and its band
|
||||||
interiors, waveform, keyboard strip, capture browser, param controls, envelope
|
interiors, waveform, keyboard strip, capture browser, param controls, envelope
|
||||||
overlay/edit). These are geometry-and-math only; the LICE draw + REAPER/VST3 plumbing is
|
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.
|
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.
|
- `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`.
|
- `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.
|
- `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`.
|
- `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
|
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
|
"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
|
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
|
norm step at 0..100 %, 0..200 % and ±100 %). Display FORMATTING is not here — `param/`'s
|
||||||
ONE time-constant formatter: every displayed time constant reads in **ms**, never seconds, so
|
`param_format` owns the one formatter per unit category, because the host and the editor must
|
||||||
two stage times are comparable at a glance. A display-unit decision only — nothing about the
|
be its two callers and neither may hold a second implementation. Links the header-only
|
||||||
stored representation changes. Links the header-only `play_seconds`, deliberately not
|
`play_seconds`, deliberately not
|
||||||
`sample_map`: `PlaySeconds` is the whole of what a deck edits, and linking the mapping would
|
`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
|
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
|
parameter set does not carry (key-track, voice count, master gain, preview velocity) and the
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ add_subdirectory(engine)
|
|||||||
add_subdirectory(map)
|
add_subdirectory(map)
|
||||||
add_subdirectory(note)
|
add_subdirectory(note)
|
||||||
add_subdirectory(ui)
|
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.
|
# Last: bake composes the three above it.
|
||||||
add_subdirectory(bake)
|
add_subdirectory(bake)
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,16 @@ decision about what the render made obsolete.
|
|||||||
- **`kStageTimeMaxSeconds` (the stage-time ceiling `param_taper` owns) is not a reset-list
|
- **`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
|
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.
|
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
|
## Modules
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,13 @@ float filterDriveDepthFromNorm(float norm) {
|
|||||||
return static_cast<float>(kFilterDriveDepthMax * n * n);
|
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) {
|
float filterNormFromQ(float q) {
|
||||||
if (!(q > kFilterQMin)) return 0.0f;
|
if (!(q > kFilterQMin)) return 0.0f;
|
||||||
if (q >= kFilterQMax) return 1.0f;
|
if (q >= kFilterQMax) return 1.0f;
|
||||||
|
|||||||
@@ -49,4 +49,9 @@ float filterNormFromQ(float q);
|
|||||||
// linear rather than merely close.
|
// linear rather than merely close.
|
||||||
float filterDriveDepthFromNorm(float norm);
|
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
|
} // namespace reasampler::instrument::engine::filter
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <cstdio>
|
|
||||||
#include <limits>
|
#include <limits>
|
||||||
|
|
||||||
namespace reasampler::instrument::engine {
|
namespace reasampler::instrument::engine {
|
||||||
@@ -37,15 +36,4 @@ double masterGainNormFromLinear(double linear) {
|
|||||||
return masterGainNormFromDb(20.0 * std::log10(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
|
} // namespace reasampler::instrument::engine
|
||||||
|
|||||||
@@ -7,8 +7,6 @@
|
|||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <cstddef>
|
|
||||||
|
|
||||||
namespace reasampler::instrument::engine {
|
namespace reasampler::instrument::engine {
|
||||||
|
|
||||||
// norm 0 is -inf (true zero); norm just above 0 starts at the finite floor kMasterGainMinDb
|
// 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.
|
// true-zero and the floor aren't representable on the knob. Out-of-range/non-finite clamps.
|
||||||
double masterGainNormFromLinear(double linear);
|
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
|
} // namespace reasampler::instrument::engine
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -204,6 +204,11 @@ LiveCommit deckParamCommit(DeckParam id) {
|
|||||||
// The one note-on-latched control; the header owns why.
|
// The one note-on-latched control; the header owns why.
|
||||||
case DeckParam::kRate:
|
case DeckParam::kRate:
|
||||||
return LiveCommit::NoteOnLatched;
|
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::kPitch:
|
||||||
case DeckParam::kAttack:
|
case DeckParam::kAttack:
|
||||||
case DeckParam::kHold:
|
case DeckParam::kHold:
|
||||||
@@ -270,7 +275,6 @@ LiveCommit deckParamCommit(DeckParam id) {
|
|||||||
case DeckParam::kVoiceCount:
|
case DeckParam::kVoiceCount:
|
||||||
case DeckParam::kVoiceMode:
|
case DeckParam::kVoiceMode:
|
||||||
case DeckParam::kMonoTrigger:
|
case DeckParam::kMonoTrigger:
|
||||||
case DeckParam::kMasterGain:
|
|
||||||
case DeckParam::kLimiterEnable:
|
case DeckParam::kLimiterEnable:
|
||||||
// MASTER's two readouts reach no parameter at all — the same footing as the overlay
|
// MASTER's two readouts reach no parameter at all — the same footing as the overlay
|
||||||
// radios above.
|
// radios above.
|
||||||
|
|||||||
@@ -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
|
// 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.
|
// 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
|
// 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 —
|
// 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
|
// resolveLoop runs once per note-on and a contour resolves against the note's own span. A live
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <cstdio>
|
|
||||||
|
|
||||||
#include "core/instrument/engine/filter/filter_morph.h" // MorphLaw (the law toggle's value)
|
#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
|
#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;
|
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
|
} // namespace reasampler::instrument::ui
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
// deck_values.h — the deck's control-id <-> parameter-set BINDING and its display units: the
|
// 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, the
|
// normalized 0..1 a knob shows, the write back into the stored seconds/fractions/positions, and
|
||||||
// double-click reset, and the ms time-constant formatter. Split from the editor shell so the
|
// the double-click reset. Split from the editor shell so the whole domain map is provable
|
||||||
// whole domain map is provable without a host; deck_groups owns WHICH controls exist, this owns
|
// without a host; deck_groups owns WHICH controls exist, this owns what each one's value MEANS,
|
||||||
// what each one's value MEANS.
|
// and param/param_format owns how it READS.
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <cstddef>
|
|
||||||
|
|
||||||
#include "core/instrument/engine/time_stretch.h" // kStretchRateMin/Max (Rate's own range)
|
#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/map/play_seconds.h" // PlaySeconds (the deck's edit target)
|
||||||
#include "core/instrument/ui/deck_groups.h" // DeckParam
|
#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.
|
// whole displayed percent and therefore take different norm steps.
|
||||||
double snapDeckParamNorm(DeckParam id, double norm);
|
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
|
} // namespace reasampler::instrument::ui
|
||||||
|
|||||||
@@ -84,6 +84,37 @@ The editor's `commitLive` is the tier-3 peer of `commitAndReload`; why it still
|
|||||||
parameter set is recorded at its declaration in `reasampler_editor.h`, and why `liveParams_` is
|
parameter set is recorded at its declaration in `reasampler_editor.h`, and why `liveParams_` is
|
||||||
declared ahead of the instrument slots at that member in `reasampler_processor.h`.
|
declared ahead of the instrument slots at that member in `reasampler_processor.h`.
|
||||||
|
|
||||||
|
**The VST3 parameter surface is a THIRD surface onto the one model, never a second copy.** The
|
||||||
|
pure half — the frozen id table, the exposed set, the plain-value layer, the formatter — is
|
||||||
|
`core/instrument/param` and is documented there; this directory only adapts it.
|
||||||
|
|
||||||
|
- **The blob stays authoritative.** `getState` serializes the model and nothing new is
|
||||||
|
persisted; the controller's own value list is a cache written FROM the model and never read
|
||||||
|
as truth. A load pushes the model into that cache through `syncParamsFromModel` WITHOUT
|
||||||
|
notifying the host, which the SDK requires.
|
||||||
|
- **`setInstrumentParams` is the notification funnel**, for the same reason it is already the
|
||||||
|
limiter mirror's: every writer of the parameter set — the editor's commits, `setState`, the
|
||||||
|
bake's adopt — passes through it, so no internal write can leave the host displaying, and on
|
||||||
|
next touch re-imposing, a superseded value. Master gain has its own funnel
|
||||||
|
(`setMasterGainLinear`) because it is the one exposed control that does not ride the
|
||||||
|
parameter set.
|
||||||
|
- **`setState` ordering against the host's first parameter block is irrelevant by
|
||||||
|
construction.** There is one model and one funnel per control, so whichever writes last wins
|
||||||
|
and the host's display follows the model either way — the ordering is not assumed, it is
|
||||||
|
removed as a question.
|
||||||
|
- **`process()` reads no parameter queue and is unchanged by the parameter surface.** A host
|
||||||
|
write arrives on the UI/main thread and reaches the audio thread through the SAME live block
|
||||||
|
the editor's knobs publish into, observed once per `render()` — block boundaries, last write
|
||||||
|
wins. `[verify — DAW]` that REAPER delivers automation to a single-component plug-in through
|
||||||
|
`IEditController::setParamNormalized` and not through `ProcessData::inputParameterChanges`
|
||||||
|
alone; if it is the latter only, an RT-safe drain is required and `process()` is where it
|
||||||
|
would have to land.
|
||||||
|
- **`IMidiMapping` is deliberately NOT implemented** — no conventional CC names most of what
|
||||||
|
is exposed, an invented map would hijack CCs the user's controller already sends, and
|
||||||
|
REAPER's own per-parameter MIDI learn covers the case without freezing anything.
|
||||||
|
`IParameterFunctionName` and `IAutomationState` are assessed and not implemented; the reasons
|
||||||
|
are in the product spec and are not re-surveyed here.
|
||||||
|
|
||||||
**Non-goals / guardrails.**
|
**Non-goals / guardrails.**
|
||||||
- The instrument never captures and never inserts into the arrange. Playback is a
|
- The instrument never captures and never inserts into the arrange. Playback is a
|
||||||
read-only act over the bank. Any instrument path that places a timeline item, or that
|
read-only act over the bank. Any instrument path that places a timeline item, or that
|
||||||
@@ -112,6 +143,7 @@ declared ahead of the instrument slots at that member in `reasampler_processor.h
|
|||||||
- `reasampler_embed` — implements `IReaperUIEmbedInterface` so the instrument draws inline in the TCP/MCP without a plugin-owned HWND; delegates layout to `embed_strip`. A read-only readout: the loaded capture across the keyboard span with its root marked, plus the activity level. It takes no mouse input (there is nothing on the strip to select).
|
- `reasampler_embed` — implements `IReaperUIEmbedInterface` so the instrument draws inline in the TCP/MCP without a plugin-owned HWND; delegates layout to `embed_strip`. A read-only readout: the loaded capture across the keyboard span with its root marked, plus the activity level. It takes no mouse input (there is nothing on the strip to select).
|
||||||
- `editor_stroke` — the editor's LICE side of the analytic stroker: builds a coverage mask with the pure `core/ui/stroke_aa` and blends it into the bitmap ONCE, writing straight to the bitmap's bits (the arithmetic matches LICE's own mode-0 combine, so a stroke composites identically to every other kit draw). Every radial and spline stroke on the editor routes through `strokeArcAA` / `strokePolylineAA` / `strokeLineAA`. Holds the draw-thread-only scratch mask and arc point list — reuse, not a hidden dependency: threading a canvas through the eight paint sites would grow those signatures to carry an allocation detail. Deliberately does NOT touch `shell/panel/draw_kit`: the waveform stroke, the docked bank panel and the browse cards are out of this seam's blast radius.
|
- `editor_stroke` — the editor's LICE side of the analytic stroker: builds a coverage mask with the pure `core/ui/stroke_aa` and blends it into the bitmap ONCE, writing straight to the bitmap's bits (the arithmetic matches LICE's own mode-0 combine, so a stroke composites identically to every other kit draw). Every radial and spline stroke on the editor routes through `strokeArcAA` / `strokePolylineAA` / `strokeLineAA`. Holds the draw-thread-only scratch mask and arc point list — reuse, not a hidden dependency: threading a canvas through the eight paint sites would grow those signatures to carry an allocation detail. Deliberately does NOT touch `shell/panel/draw_kit`: the waveform stroke, the docked bank panel and the browse cards are out of this seam's blast radius.
|
||||||
- `instrument_bake` — the instrument's half of the resample chain, on the UI thread: render the dialed sound through the pure `core/instrument/bake` modules at the instance's PERSISTED PREVIEW VELOCITY (the velocity the user has been auditioning at — three velocity curves are live, so it is a property of the sound and not a render detail), stage the WAV OUTSIDE the bank folder, publish one `rsbake_<guid>` request, invoke the extension's landing action SYNCHRONOUSLY, read the outcome back over the same key, then adopt + reset in one act. What that key holds afterwards is classified by `core/wire`'s pure `classifyBakeAnswer`, and each of its five non-answers gets its OWN sentence — a silent no-answer stays a failure, but the user is told whether nothing wrote over the key, a stale generation was answered, the answer came in a wire this build cannot read, the request was cleared, or it was refused. All five name the key, because the extension prints one console line per key it scanned and the key is what correlates the two in a multi-instance session. None of them claims the landing never ran — nothing on this side can observe that. Two stack-RAII guards mirror `FxBypassGuard`'s discipline: the staged file and the request key are both cleared on every exit path, so a failed bake leaves no temp, no bank entry and no parameter reset. `bakeAvailable` is the affordance's paint gate. A cloned `instanceGuid` (two instances sharing one `rsbake_` key) is NOT handled here — the residual is contained by pre-existing tracking machinery instead: `planUsagePublish`'s sticky `unioned` poison plus `tiedUsageExists` (`core/tracking/tracking_authority.cpp`) force a clone's bake to `AddDistinct` rather than silently replacing a sibling's entry.
|
- `instrument_bake` — the instrument's half of the resample chain, on the UI thread: render the dialed sound through the pure `core/instrument/bake` modules at the instance's PERSISTED PREVIEW VELOCITY (the velocity the user has been auditioning at — three velocity curves are live, so it is a property of the sound and not a render detail), stage the WAV OUTSIDE the bank folder, publish one `rsbake_<guid>` request, invoke the extension's landing action SYNCHRONOUSLY, read the outcome back over the same key, then adopt + reset in one act. What that key holds afterwards is classified by `core/wire`'s pure `classifyBakeAnswer`, and each of its five non-answers gets its OWN sentence — a silent no-answer stays a failure, but the user is told whether nothing wrote over the key, a stale generation was answered, the answer came in a wire this build cannot read, the request was cleared, or it was refused. All five name the key, because the extension prints one console line per key it scanned and the key is what correlates the two in a multi-instance session. None of them claims the landing never ran — nothing on this side can observe that. Two stack-RAII guards mirror `FxBypassGuard`'s discipline: the staged file and the request key are both cleared on every exit path, so a failed bake leaves no temp, no bank entry and no parameter reset. `bakeAvailable` is the affordance's paint gate. A cloned `instanceGuid` (two instances sharing one `rsbake_` key) is NOT handled here — the residual is contained by pre-existing tracking machinery instead: `planUsagePublish`'s sticky `unioned` poison plus `tiedUsageExists` (`core/tracking/tracking_authority.cpp`) force a clone's bake to `AddDistinct` rather than silently replacing a sibling's entry.
|
||||||
|
- `instrument_params` — the VST3 adapter over `core/instrument/param`: one `Parameter` subclass whose `toPlain`/`toNormalized` ARE the taper and whose `toString` calls the one formatter, the single construction of the unit and parameter lists (ascending id, which is also the presentation order), the `setParamNormalized` projection onto the model through each control's existing commit tier, and the `beginEdit`/`performEdit`/`endEdit` notification path every internal writer reaches through `setInstrumentParams`. Decides nothing — the pure module owns the table, the laws and the formatter.
|
||||||
- `vst_entry` — VST3 entry point: `GetPluginFactory` export, class registration, channel-forked class UIDs.
|
- `vst_entry` — VST3 entry point: `GetPluginFactory` export, class registration, channel-forked class UIDs.
|
||||||
- `editor_interaction.h` — the editor's INTERACTION VOCABULARY: `DragKind` (what a gesture in flight is editing) and `HoverKind`/`HoverTarget` (what the pointer can be over). Split out of `reasampler_editor.h`, which had grown past the ~600-line ceiling with no seam — these two catalogues are produced by the input TUs and read by the paint TUs, and neither is behaviour, which is what makes them a responsibility rather than a bisection. Namespace-scope, so the editor's own members still spell them unqualified. Internal to this TU family, like `editor_internal.h`.
|
- `editor_interaction.h` — the editor's INTERACTION VOCABULARY: `DragKind` (what a gesture in flight is editing) and `HoverKind`/`HoverTarget` (what the pointer can be over). Split out of `reasampler_editor.h`, which had grown past the ~600-line ceiling with no seam — these two catalogues are produced by the input TUs and read by the paint TUs, and neither is behaviour, which is what makes them a responsibility rather than a bisection. Namespace-scope, so the editor's own members still spell them unqualified. Internal to this TU family, like `editor_internal.h`.
|
||||||
- `editor_internal.h` — INTERNAL shared helpers for the `reasampler_editor` TU family, included only by the editor's own shell TUs (`editor_session` / `editor_controls` / `editor_paint_*` / `editor_input_*` / `editor_platform`), never a public seam: the `Rect`↔kit adapters, small draw primitives (knob face / title band), label helpers, the velocity-curve box derivation, and `dragModifiers()` — THE modifier read for every drag surface and gesture resolver, so the editor cannot grow a second modifier grammar — the helpers more than one band TU needs. The deck's control ids, group ids and group composition are the pure `deck_groups` module's, not this file's. The piano-strip and root-key draws live in `editor_paint_chrome`, their only consumer, not here.
|
- `editor_internal.h` — INTERNAL shared helpers for the `reasampler_editor` TU family, included only by the editor's own shell TUs (`editor_session` / `editor_controls` / `editor_paint_*` / `editor_input_*` / `editor_platform`), never a public seam: the `Rect`↔kit adapters, small draw primitives (knob face / title band), label helpers, the velocity-curve box derivation, and `dragModifiers()` — THE modifier read for every drag surface and gesture resolver, so the editor cannot grow a second modifier grammar — the helpers more than one band TU needs. The deck's control ids, group ids and group composition are the pure `deck_groups` module's, not this file's. The piano-strip and root-key draws live in `editor_paint_chrome`, their only consumer, not here.
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp")
|
|||||||
reasampler_processor.cpp
|
reasampler_processor.cpp
|
||||||
processor_state.cpp
|
processor_state.cpp
|
||||||
processor_reload.cpp
|
processor_reload.cpp
|
||||||
|
instrument_params.cpp
|
||||||
# The editor family is split on the Sample face's band axis: session/bridge state,
|
# The editor family is split on the Sample face's band axis: session/bridge state,
|
||||||
# param plumbing plus the shared band-layout resolve, then paint and input in
|
# param plumbing plus the shared band-layout resolve, then paint and input in
|
||||||
# matching sets, plus the two band-independent surfaces and the platform TU.
|
# matching sets, plus the two band-independent surfaces and the platform TU.
|
||||||
@@ -89,6 +90,7 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp")
|
|||||||
waveform_view loop_marks bank_sync browser_scroll param_slider tooltip
|
waveform_view loop_marks bank_sync browser_scroll param_slider tooltip
|
||||||
theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit
|
theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit
|
||||||
knob_deck deck_groups deck_values curve_popup spline_edit master_gain sample_usage
|
knob_deck deck_groups deck_values curve_popup spline_edit master_gain sample_usage
|
||||||
|
param_id param_units param_format
|
||||||
limiter meter_accumulate meter_ballistics master_meter bake_hold
|
limiter meter_accumulate meter_ballistics master_meter bake_hold
|
||||||
file_bytes curve_law stroke_aa
|
file_bytes curve_law stroke_aa
|
||||||
curve_tessellate
|
curve_tessellate
|
||||||
|
|||||||
@@ -13,11 +13,12 @@
|
|||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include "core/instrument/engine/filter/filter_params.h" // the filter's own control laws
|
|
||||||
#include "core/instrument/engine/master_gain.h" // master-gain dB<->linear<->knob taper
|
#include "core/instrument/engine/master_gain.h" // master-gain dB<->linear<->knob taper
|
||||||
|
#include "core/instrument/param/param_format.h" // THE formatter every value label reads through
|
||||||
|
#include "core/instrument/param/param_id.h" // whether a control has a parameter row at all
|
||||||
#include "core/instrument/ui/bake_hold.h" // the Hold knob's ladder map
|
#include "core/instrument/ui/bake_hold.h" // the Hold knob's ladder map
|
||||||
#include "core/instrument/ui/deck_groups.h" // sampleDeckGroups (the deck's composition)
|
#include "core/instrument/ui/deck_groups.h" // sampleDeckGroups (the deck's composition)
|
||||||
#include "core/instrument/ui/deck_values.h" // the parameter-set binding + its ms units
|
#include "core/instrument/ui/deck_values.h" // the parameter-set binding
|
||||||
#include "core/instrument/ui/knob_deck.h" // deckHeight / kDeckKnobSize (the band's own height)
|
#include "core/instrument/ui/knob_deck.h" // deckHeight / kDeckKnobSize (the band's own height)
|
||||||
#include "core/util/clamp01.h"
|
#include "core/util/clamp01.h"
|
||||||
#include "core/util/curve_law.h" // the ONE curve-exponent domain
|
#include "core/util/curve_law.h" // the ONE curve-exponent domain
|
||||||
@@ -33,18 +34,13 @@ using instrument::ui::deckHeight;
|
|||||||
using instrument::ui::kDeckKnobSize;
|
using instrument::ui::kDeckKnobSize;
|
||||||
using instrument::ui::kPad;
|
using instrument::ui::kPad;
|
||||||
using instrument::ui::deckParamNorm;
|
using instrument::ui::deckParamNorm;
|
||||||
using instrument::ui::formatEnvTimeMs;
|
|
||||||
using instrument::ui::kEnvTimeMaxSeconds;
|
using instrument::ui::kEnvTimeMaxSeconds;
|
||||||
using instrument::ui::kKeyTrackMax;
|
using instrument::ui::kKeyTrackMax;
|
||||||
using instrument::ui::resetDeckParam;
|
using instrument::ui::resetDeckParam;
|
||||||
using instrument::ui::sampleDeckGroups;
|
using instrument::ui::sampleDeckGroups;
|
||||||
using instrument::ui::setDeckParam;
|
using instrument::ui::setDeckParam;
|
||||||
using instrument::engine::formatMasterGainLabel;
|
|
||||||
using instrument::engine::masterGainLinearFromNorm;
|
using instrument::engine::masterGainLinearFromNorm;
|
||||||
using instrument::engine::masterGainNormFromLinear;
|
using instrument::engine::masterGainNormFromLinear;
|
||||||
using instrument::engine::filter::filterCutoffHzFromNorm;
|
|
||||||
using instrument::engine::filter::filterDriveDepthFromNorm;
|
|
||||||
using instrument::engine::filter::filterQFromNorm;
|
|
||||||
using util::clamp01;
|
using util::clamp01;
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
@@ -220,117 +216,36 @@ void ReaSamplerEditor::applyDeckKnob(int id, double norm) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string ReaSamplerEditor::deckValueLabel(int id) const {
|
double ReaSamplerEditor::deckPlainValue(int id) const {
|
||||||
char buf[24];
|
const auto deck = static_cast<instrument::ui::DeckParam>(id);
|
||||||
buf[0] = '\0';
|
// A curve exponent is read off its stored field, never round-tripped through the knob law:
|
||||||
const PlaySeconds& play = params_.play;
|
// that law's centre detent snaps anything near-neutral back to exactly 1.0, so a round trip
|
||||||
switch (id < 0 ? ParamControl::kCount : static_cast<ParamControl>(id)) {
|
// would misreport a stored exponent that isn't neutral as 1.00. The host has only the norm
|
||||||
case ParamControl::kAttack:
|
// and therefore cannot make this distinction — param/CLAUDE.md records the divergence.
|
||||||
formatEnvTimeMs(play.adsr.attackSeconds, buf, sizeof(buf)); break;
|
if (instrument::ui::deckParamUnit(deck) == instrument::ui::UnitCategory::Exponent) {
|
||||||
case ParamControl::kHold:
|
return curveExponentFor(id, params_.play);
|
||||||
formatEnvTimeMs(play.adsr.holdSeconds, buf, sizeof(buf)); break;
|
|
||||||
case ParamControl::kDecay:
|
|
||||||
formatEnvTimeMs(play.adsr.decaySeconds, buf, sizeof(buf)); break;
|
|
||||||
case ParamControl::kSustain:
|
|
||||||
snprintf(buf, sizeof(buf), "%.0f%%", play.adsr.sustainLevel * 100.0); break;
|
|
||||||
case ParamControl::kRelease:
|
|
||||||
formatEnvTimeMs(play.adsr.releaseSeconds, buf, sizeof(buf)); break;
|
|
||||||
case ParamControl::kTrigLength:
|
|
||||||
snprintf(buf, sizeof(buf), "%.0f%%", play.trigger.lengthFraction * 100.0); break;
|
|
||||||
case ParamControl::kTrigAttack:
|
|
||||||
formatEnvTimeMs(play.trigAhd.attackSeconds, buf, sizeof(buf)); break;
|
|
||||||
case ParamControl::kTrigHold:
|
|
||||||
snprintf(buf, sizeof(buf), "%.0f%%", play.trigAhd.holdFraction * 100.0); break;
|
|
||||||
case ParamControl::kTrigDecay:
|
|
||||||
formatEnvTimeMs(play.trigAhd.decaySeconds, buf, sizeof(buf)); break;
|
|
||||||
case ParamControl::kPitchEnvAttack:
|
|
||||||
formatEnvTimeMs(play.pitchEnv.shape.attackSeconds, buf, sizeof(buf)); break;
|
|
||||||
case ParamControl::kPitchEnvHold:
|
|
||||||
snprintf(buf, sizeof(buf), "%.0f%%", play.pitchEnv.shape.holdFraction * 100.0); break;
|
|
||||||
case ParamControl::kPitchEnvDecay:
|
|
||||||
formatEnvTimeMs(play.pitchEnv.shape.decaySeconds, buf, sizeof(buf)); break;
|
|
||||||
case ParamControl::kPitchEnvDepth:
|
|
||||||
snprintf(buf, sizeof(buf), "%+.1fst", play.pitchEnv.peakSemitones); break;
|
|
||||||
case ParamControl::kKeyTrack:
|
|
||||||
snprintf(buf, sizeof(buf), "%.0f%%", params_.keyTrack * 100.0); break;
|
|
||||||
case ParamControl::kRate: {
|
|
||||||
// One decimal below 100 % only: the taper is linear in semitones, so the lower half
|
|
||||||
// spends 50 percentage points on the same twelve semitones the upper half spends
|
|
||||||
// 100 on — a whole percent is twice as coarse a step down there.
|
|
||||||
const double pct = play.playRate * 100.0;
|
|
||||||
snprintf(buf, sizeof(buf), pct < 100.0 ? "%.1f%%" : "%.0f%%", pct);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case ParamControl::kPitch:
|
|
||||||
snprintf(buf, sizeof(buf), "%+.1fst", play.pitchOffsetSemitones); break;
|
|
||||||
case ParamControl::kVoiceCount:
|
|
||||||
snprintf(buf, sizeof(buf), "%d", voiceCount_); break;
|
|
||||||
case ParamControl::kMasterGain:
|
|
||||||
formatMasterGainLabel(deckControlNorm(id), buf, sizeof(buf)); break;
|
|
||||||
// Filter readouts run the stored normalized positions back through the module's OWN
|
|
||||||
// laws, so what the label says is what the kernel is solved for.
|
|
||||||
case ParamControl::kFilterMorph: {
|
|
||||||
const double m = play.filter.settings.morphNorm;
|
|
||||||
snprintf(buf, sizeof(buf), "%.0f%%", m * 100.0);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case ParamControl::kFilterCutoff: {
|
|
||||||
const float hz = filterCutoffHzFromNorm(play.filter.settings.cutoffNorm);
|
|
||||||
if (hz >= 1000.0f) snprintf(buf, sizeof(buf), "%.2fk", hz / 1000.0f);
|
|
||||||
else snprintf(buf, sizeof(buf), "%.0fHz", hz);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case ParamControl::kFilterQ:
|
|
||||||
snprintf(buf, sizeof(buf), "%.2f",
|
|
||||||
static_cast<double>(filterQFromNorm(play.filter.settings.resonanceNorm)));
|
|
||||||
break;
|
|
||||||
case ParamControl::kFilterDrive:
|
|
||||||
snprintf(buf, sizeof(buf), "%.2f",
|
|
||||||
static_cast<double>(filterDriveDepthFromNorm(play.filter.settings.driveNorm)));
|
|
||||||
break;
|
|
||||||
case ParamControl::kFilterModAmt:
|
|
||||||
snprintf(buf, sizeof(buf), "%+.0f%%", play.filter.modAmount * 100.0); break;
|
|
||||||
case ParamControl::kFilterVel:
|
|
||||||
snprintf(buf, sizeof(buf), "%+.0f%%", play.filter.velAmount * 100.0); break;
|
|
||||||
case ParamControl::kFilterKeyTrack:
|
|
||||||
snprintf(buf, sizeof(buf), "%.0f%%", play.filter.keyTrack * 100.0); break;
|
|
||||||
case ParamControl::kFilterEnvAttack:
|
|
||||||
formatEnvTimeMs(play.filter.env.attackSeconds, buf, sizeof(buf)); break;
|
|
||||||
case ParamControl::kFilterEnvHold:
|
|
||||||
formatEnvTimeMs(play.filter.env.holdSeconds, buf, sizeof(buf)); break;
|
|
||||||
case ParamControl::kFilterEnvDecay:
|
|
||||||
formatEnvTimeMs(play.filter.env.decaySeconds, buf, sizeof(buf)); break;
|
|
||||||
case ParamControl::kFilterEnvSustain:
|
|
||||||
snprintf(buf, sizeof(buf), "%.0f%%", play.filter.env.sustainLevel * 100.0); break;
|
|
||||||
case ParamControl::kFilterEnvRelease:
|
|
||||||
formatEnvTimeMs(play.filter.env.releaseSeconds, buf, sizeof(buf)); break;
|
|
||||||
case ParamControl::kFilterTrigAttack:
|
|
||||||
formatEnvTimeMs(play.filter.trigEnv.attackSeconds, buf, sizeof(buf)); break;
|
|
||||||
case ParamControl::kFilterTrigHold:
|
|
||||||
snprintf(buf, sizeof(buf), "%.0f%%", play.filter.trigEnv.holdFraction * 100.0); break;
|
|
||||||
case ParamControl::kFilterTrigDecay:
|
|
||||||
formatEnvTimeMs(play.filter.trigEnv.decaySeconds, buf, sizeof(buf)); break;
|
|
||||||
// Every curve exponent reads the same way: the neutral shows as 1.00.
|
|
||||||
case ParamControl::kAttackCurve:
|
|
||||||
case ParamControl::kDecayCurve:
|
|
||||||
case ParamControl::kReleaseCurve:
|
|
||||||
case ParamControl::kTrigAttackCurve:
|
|
||||||
case ParamControl::kTrigDecayCurve:
|
|
||||||
case ParamControl::kPitchEnvAttackCurve:
|
|
||||||
case ParamControl::kPitchEnvDecayCurve:
|
|
||||||
case ParamControl::kFilterEnvAttackCurve:
|
|
||||||
case ParamControl::kFilterEnvDecayCurve:
|
|
||||||
case ParamControl::kFilterEnvReleaseCurve:
|
|
||||||
case ParamControl::kFilterTrigAttackCurve:
|
|
||||||
case ParamControl::kFilterTrigDecayCurve:
|
|
||||||
snprintf(buf, sizeof(buf), "^%.2f", curveExponentFor(id, play));
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
// The chrome knobs (preview velocity, bake Hold) are labeled at their own call
|
|
||||||
// site; nothing else here.
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
return std::string(buf);
|
return instrument::param::toPlain(deck, deckControlNorm(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string ReaSamplerEditor::deckValueLabel(int id) const {
|
||||||
|
if (id < 0 || id >= static_cast<int>(ParamControl::kCount)) return {};
|
||||||
|
const auto deck = static_cast<instrument::ui::DeckParam>(id);
|
||||||
|
// The one deck knob with no plain-value layer at all: an already-integer count.
|
||||||
|
if (deck == ParamControl::kVoiceCount) {
|
||||||
|
char buf[24];
|
||||||
|
snprintf(buf, sizeof(buf), "%d", voiceCount_);
|
||||||
|
return std::string(buf);
|
||||||
|
}
|
||||||
|
if (instrument::param::paramIdFor(deck) == 0) return {};
|
||||||
|
|
||||||
|
// The digits come from the ONE formatter; everything the editor adds around them is static
|
||||||
|
// chrome — a constant prefix or suffix cannot diverge from what the host shows.
|
||||||
|
char digits[24];
|
||||||
|
instrument::param::formatPlainFor(deck, deckPlainValue(id), digits, sizeof(digits));
|
||||||
|
const char* caret =
|
||||||
|
instrument::ui::deckParamUnit(deck) == instrument::ui::UnitCategory::Exponent ? "^" : "";
|
||||||
|
return caret + std::string(digits) + instrument::param::unitStringFor(deck);
|
||||||
}
|
}
|
||||||
|
|
||||||
EnvClampBounds ReaSamplerEditor::envClampBounds() const {
|
EnvClampBounds ReaSamplerEditor::envClampBounds() const {
|
||||||
|
|||||||
@@ -85,6 +85,13 @@ void ReaSamplerEditor::onMouseUp(int x, int y) {
|
|||||||
invalidate();
|
invalidate();
|
||||||
}
|
}
|
||||||
if (drag_ == DragKind::kNone) return;
|
if (drag_ == DragKind::kNone) return;
|
||||||
|
// Stack RAII rather than a call at each exit: this handler leaves through several early
|
||||||
|
// returns, and the release commit's final performEdit must land INSIDE the bracket the grab
|
||||||
|
// opened while the bracket itself may not outlive the handler on any path.
|
||||||
|
struct GestureClose {
|
||||||
|
ReaSamplerProcessor* p;
|
||||||
|
~GestureClose() { if (p) p->endParamGesture(); }
|
||||||
|
} gestureClose{processor_};
|
||||||
const DragKind kind = drag_;
|
const DragKind kind = drag_;
|
||||||
const int paramId = dragParamId_;
|
const int paramId = dragParamId_;
|
||||||
const int curveIdx = curvePointIndex_;
|
const int curveIdx = curvePointIndex_;
|
||||||
@@ -107,9 +114,7 @@ void ReaSamplerEditor::onMouseUp(int x, int y) {
|
|||||||
// directly. Voice count: the label/needle tracks live during the drag but the engine
|
// directly. Voice count: the label/needle tracks live during the drag but the engine
|
||||||
// rebuild (setVoiceCount) fires ONCE here on release — not per integer step.
|
// rebuild (setVoiceCount) fires ONCE here on release — not per integer step.
|
||||||
const bool deckTransient =
|
const bool deckTransient =
|
||||||
kind == DragKind::kDeckKnob &&
|
kind == DragKind::kDeckKnob && deckKnobIsProcessorSide(paramId);
|
||||||
(paramId == -2 || paramId == static_cast<int>(ParamControl::kVoiceCount) ||
|
|
||||||
paramId == static_cast<int>(ParamControl::kMasterGain));
|
|
||||||
if (kind == DragKind::kScrollThumb || deckTransient) {
|
if (kind == DragKind::kScrollThumb || deckTransient) {
|
||||||
// Commit the voice count now that the drag is complete (one rebuild per full drag).
|
// Commit the voice count now that the drag is complete (one rebuild per full drag).
|
||||||
if (deckTransient && processor_ &&
|
if (deckTransient && processor_ &&
|
||||||
|
|||||||
@@ -125,6 +125,12 @@ bool ReaSamplerEditor::mouseDownDeck(const FaceLayout& fl, int x, int y) {
|
|||||||
dragStartParams_ = params_;
|
dragStartParams_ = params_;
|
||||||
dragStartX_ = x;
|
dragStartX_ = x;
|
||||||
dragStartY_ = y;
|
dragStartY_ = y;
|
||||||
|
// Opens the host's edit bracket for the whole gesture, so a host in touch or latch mode
|
||||||
|
// records one continuous edit rather than a burst of one-point ones. Closed on release
|
||||||
|
// and on capture-lost; a control with no parameter row is a no-op inside the processor.
|
||||||
|
if (processor_) {
|
||||||
|
processor_->beginParamGesture(static_cast<instrument::ui::DeckParam>(dragParamId_));
|
||||||
|
}
|
||||||
invalidate();
|
invalidate();
|
||||||
}
|
}
|
||||||
// The deck band swallows its own clicks either way — no fall-through to the waveform.
|
// The deck band swallows its own clicks either way — no fall-through to the waveform.
|
||||||
@@ -188,8 +194,14 @@ void ReaSamplerEditor::dragDeck(int x, int y) {
|
|||||||
}
|
}
|
||||||
applyDeckKnob(dragParamId_, norm);
|
applyDeckKnob(dragParamId_, norm);
|
||||||
// A live control is delivered on every move, not only on release — that is the whole
|
// A live control is delivered on every move, not only on release — that is the whole
|
||||||
// point: the note already sounding tracks the hand on the knob.
|
// point: the note already sounding tracks the hand on the knob. The processor-side knobs
|
||||||
if (dragCommitsLive(DragKind::kDeckKnob, dragParamId_)) commitLive();
|
// are skipped because applyDeckKnob already wrote them straight through; commitLive would
|
||||||
|
// only re-push an unchanged parameter set. The release and capture-lost paths ask this same
|
||||||
|
// question; the reset path never reaches the commit for them at all.
|
||||||
|
if (!deckKnobIsProcessorSide(dragParamId_) &&
|
||||||
|
dragCommitsLive(DragKind::kDeckKnob, dragParamId_)) {
|
||||||
|
commitLive();
|
||||||
|
}
|
||||||
invalidate();
|
invalidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -249,9 +249,7 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
|
|||||||
// abandoned value indefinitely instead of rolling back.
|
// abandoned value indefinitely instead of rolling back.
|
||||||
const bool transient = self->drag_ == DragKind::kScrollThumb ||
|
const bool transient = self->drag_ == DragKind::kScrollThumb ||
|
||||||
(self->drag_ == DragKind::kDeckKnob &&
|
(self->drag_ == DragKind::kDeckKnob &&
|
||||||
(self->dragParamId_ == -2 ||
|
ReaSamplerEditor::deckKnobIsProcessorSide(self->dragParamId_));
|
||||||
self->dragParamId_ == static_cast<int>(ParamControl::kVoiceCount) ||
|
|
||||||
self->dragParamId_ == static_cast<int>(ParamControl::kMasterGain)));
|
|
||||||
if (!transient) {
|
if (!transient) {
|
||||||
self->params_ = self->dragStartParams_;
|
self->params_ = self->dragStartParams_;
|
||||||
// A live drag already reached the voices AND the processor's own
|
// A live drag already reached the voices AND the processor's own
|
||||||
@@ -263,6 +261,10 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
|
|||||||
self->commitLive();
|
self->commitLive();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Peer of onMouseUp's bracket close, and after the rollback for the same
|
||||||
|
// reason: the rollback's own performEdit belongs inside the bracket the grab
|
||||||
|
// opened, and an abandoned drag must not leave the host's edit open.
|
||||||
|
if (self->processor_) self->processor_->endParamGesture();
|
||||||
self->drag_ = DragKind::kNone;
|
self->drag_ = DragKind::kNone;
|
||||||
self->dragParamId_ = -1;
|
self->dragParamId_ = -1;
|
||||||
self->dragInnerCellId_ = -1; // inner-dial drag state (peer reset)
|
self->dragInnerCellId_ = -1; // inner-dial drag state (peer reset)
|
||||||
|
|||||||
@@ -213,6 +213,12 @@ void ReaSamplerEditor::commitLive() {
|
|||||||
processor_->publishLiveParams();
|
processor_->publishLiveParams();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool ReaSamplerEditor::deckKnobIsProcessorSide(int paramId) {
|
||||||
|
return paramId == -2 ||
|
||||||
|
paramId == static_cast<int>(ParamControl::kVoiceCount) ||
|
||||||
|
paramId == static_cast<int>(ParamControl::kMasterGain);
|
||||||
|
}
|
||||||
|
|
||||||
bool ReaSamplerEditor::dragCommitsLive(DragKind kind, int paramId) const {
|
bool ReaSamplerEditor::dragCommitsLive(DragKind kind, int paramId) const {
|
||||||
// The decision itself is the pure liveCommitFor's; this is only the shell's drag-kind
|
// The decision itself is the pure liveCommitFor's; this is only the shell's drag-kind
|
||||||
// vocabulary mapped onto it, so the routing is pinned by deck_groups' tests rather than
|
// vocabulary mapped onto it, so the routing is pinned by deck_groups' tests rather than
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
// instrument_params.cpp — the VST3 adapter over core/instrument/param: the Parameter subclass
|
||||||
|
// whose toPlain/toNormalized ARE the taper, the one construction of the unit and parameter
|
||||||
|
// lists, and the model projection both directions. It DECIDES nothing — the pure module owns
|
||||||
|
// the frozen table, the laws and the formatter.
|
||||||
|
//
|
||||||
|
// The blob stays authoritative. A parameter is a THIRD SURFACE onto InstrumentParams/PlaySeconds
|
||||||
|
// — a peer of the deck knob and the overlay node, never a second copy of the value. getState
|
||||||
|
// serializes the model; the controller's own value list is a cache written FROM the model and
|
||||||
|
// never read as truth.
|
||||||
|
|
||||||
|
#include "shell/instrument/reasampler_processor.h"
|
||||||
|
|
||||||
|
#include "base/source/fstring.h"
|
||||||
|
#include "pluginterfaces/base/ustring.h"
|
||||||
|
|
||||||
|
#include "core/instrument/engine/master_gain.h"
|
||||||
|
#include "core/instrument/param/param_format.h"
|
||||||
|
#include "core/instrument/param/param_id.h"
|
||||||
|
#include "core/instrument/param/param_units.h"
|
||||||
|
#include "core/instrument/ui/deck_values.h"
|
||||||
|
|
||||||
|
using namespace Steinberg;
|
||||||
|
using namespace Steinberg::Vst;
|
||||||
|
|
||||||
|
namespace reasampler::vst {
|
||||||
|
|
||||||
|
namespace param = instrument::param;
|
||||||
|
using instrument::ui::DeckParam;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
void assign128(String128 dst, const char* src) {
|
||||||
|
UString(dst, str16BufferSize(String128)).fromAscii(src);
|
||||||
|
}
|
||||||
|
|
||||||
|
// One class for all of them: the law is per-control data inside the pure module, so a subclass
|
||||||
|
// per unit category would model nothing that a DeckParam does not already say.
|
||||||
|
class DeckParameter : public Parameter {
|
||||||
|
public:
|
||||||
|
explicit DeckParameter(const param::ParamRow& row) : deck_(row.deck) {
|
||||||
|
assign128(info.title, row.title);
|
||||||
|
assign128(info.shortTitle, row.shortTitle);
|
||||||
|
assign128(info.units, param::unitStringFor(row.deck));
|
||||||
|
info.id = row.id;
|
||||||
|
info.unitId = row.unit;
|
||||||
|
// Continuous, every one of them — and structurally so rather than by luck: stepCount > 0
|
||||||
|
// is only meaningful for a discrete control, and every discrete control is reload or
|
||||||
|
// rebuild tier and therefore not exposed at all. The editor's shift-snap is a DRAG
|
||||||
|
// interaction and must never be published here: stepCount quantizes the parameter
|
||||||
|
// permanently, for the host's automation too, and freezes into the forever contract.
|
||||||
|
info.stepCount = 0;
|
||||||
|
// COMPUTED from the default, never a normalized literal — a hand-written normalized
|
||||||
|
// default is a second source of truth for it and drifts from the taper silently.
|
||||||
|
info.defaultNormalizedValue = param::defaultNormalized(row.deck);
|
||||||
|
// No kIsBypass on anything: the plugin is an instrument and exposes no bypass.
|
||||||
|
info.flags = ParameterInfo::kCanAutomate;
|
||||||
|
valueNormalized = info.defaultNormalizedValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
ParamValue toPlain(ParamValue normalized) const SMTG_OVERRIDE {
|
||||||
|
return param::toPlain(deck_, normalized);
|
||||||
|
}
|
||||||
|
ParamValue toNormalized(ParamValue plain) const SMTG_OVERRIDE {
|
||||||
|
return param::toNormalized(deck_, plain);
|
||||||
|
}
|
||||||
|
void toString(ParamValue normalized, String128 out) const SMTG_OVERRIDE {
|
||||||
|
char digits[24];
|
||||||
|
param::formatPlainFor(deck_, param::toPlain(deck_, normalized), digits, sizeof(digits));
|
||||||
|
assign128(out, digits);
|
||||||
|
}
|
||||||
|
bool fromString(const TChar* text, ParamValue& normalized) const SMTG_OVERRIDE {
|
||||||
|
String str(text);
|
||||||
|
str.toMultiByte(kCP_Utf8);
|
||||||
|
double plain = 0.0;
|
||||||
|
if (!param::parsePlain(param::unitKindFor(deck_), str.text8(), plain)) return false;
|
||||||
|
normalized = param::toNormalized(deck_, plain);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
OBJ_METHODS(DeckParameter, Parameter)
|
||||||
|
|
||||||
|
private:
|
||||||
|
DeckParam deck_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void ReaSamplerProcessor::buildParameterList() {
|
||||||
|
// One unit per deck group that carries an exposed parameter, so a host can present the list
|
||||||
|
// under its group names rather than as one flat run.
|
||||||
|
struct UnitDesc { UnitID id; const char* name; };
|
||||||
|
static const UnitDesc kUnits[] = {
|
||||||
|
{param::kUnitPitch, "Pitch"},
|
||||||
|
{param::kUnitPitchEnv, "Pitch Env"},
|
||||||
|
{param::kUnitFilter, "Filter"},
|
||||||
|
{param::kUnitFilterEnv, "Filter Env"},
|
||||||
|
{param::kUnitAmp, "Amp Env"},
|
||||||
|
{param::kUnitMaster, "Master"},
|
||||||
|
};
|
||||||
|
for (const UnitDesc& u : kUnits) {
|
||||||
|
String128 name;
|
||||||
|
assign128(name, u.name);
|
||||||
|
addUnit(new Unit(name, u.id));
|
||||||
|
}
|
||||||
|
// Ascending id IS the presentation order, which is what makes identity order and
|
||||||
|
// presentation order agree by construction rather than by maintenance.
|
||||||
|
for (const param::ParamRow& row : param::exposedParams()) {
|
||||||
|
parameters.addParameter(new DeckParameter(row));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tresult PLUGIN_API ReaSamplerProcessor::setParamNormalized(ParamID tag, ParamValue value) {
|
||||||
|
const param::ParamRow* row = param::exposedRowFor(tag);
|
||||||
|
if (!row) return kResultFalse;
|
||||||
|
// Notification is suppressed for the duration: this write CAME from the host, and echoing it
|
||||||
|
// back through performEdit would let a lane in write mode re-record its own playback.
|
||||||
|
const bool wasSuppressed = paramNotifySuppressed_;
|
||||||
|
paramNotifySuppressed_ = true;
|
||||||
|
// The host's write takes the control's EXISTING commit tier and no other. Nothing here can
|
||||||
|
// reach reloadInstrument or rebuildVoiceEngine, and that is structural: every reload- and
|
||||||
|
// rebuild-tier control is omitted from the list, so no id maps to one.
|
||||||
|
if (row->deck == DeckParam::kMasterGain) {
|
||||||
|
setMasterGainLinear(instrument::engine::masterGainLinearFromNorm(value));
|
||||||
|
} else {
|
||||||
|
InstrumentParams params = instrumentParams();
|
||||||
|
instrument::ui::setDeckParam(row->deck, params.play, value, /*segment=*/0);
|
||||||
|
setInstrumentParams(params);
|
||||||
|
publishLiveParams();
|
||||||
|
}
|
||||||
|
paramNotifySuppressed_ = wasSuppressed;
|
||||||
|
// The container caches what the MODEL took, not what the host sent — a control whose write
|
||||||
|
// clamped would otherwise read back the out-of-range value the clamp rejected.
|
||||||
|
return EditControllerEx1::setParamNormalized(
|
||||||
|
tag, modelParamNormalized(instrumentParams(), row->deck));
|
||||||
|
}
|
||||||
|
|
||||||
|
double ReaSamplerProcessor::modelParamNormalized(const InstrumentParams& params,
|
||||||
|
DeckParam deck) const {
|
||||||
|
if (deck == DeckParam::kMasterGain) {
|
||||||
|
return instrument::engine::masterGainNormFromLinear(masterGainLinear());
|
||||||
|
}
|
||||||
|
return instrument::ui::deckParamNorm(deck, params.play);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ReaSamplerProcessor::syncParamsFromModel() {
|
||||||
|
const InstrumentParams params = instrumentParams();
|
||||||
|
for (const param::ParamRow& row : param::exposedParams()) {
|
||||||
|
// EditControllerEx1's own setter, NOT ours: this is the LOAD direction, and the SDK is
|
||||||
|
// explicit that a controller must never pass a load back to the host through
|
||||||
|
// IComponentHandler — it updates the GUI element only.
|
||||||
|
EditControllerEx1::setParamNormalized(row.id, modelParamNormalized(params, row.deck));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ReaSamplerProcessor::notifyParamsFromModel(const InstrumentParams& before,
|
||||||
|
const InstrumentParams& after) {
|
||||||
|
if (paramNotifySuppressed_) return;
|
||||||
|
for (const param::ParamRow& row : param::exposedParams()) {
|
||||||
|
if (row.deck == DeckParam::kMasterGain) continue; // its own funnel notifies it
|
||||||
|
const double now = modelParamNormalized(after, row.deck);
|
||||||
|
if (now == modelParamNormalized(before, row.deck)) continue;
|
||||||
|
notifyParamChanged(row.id, now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ReaSamplerProcessor::notifyParamChanged(param::ParamId id, double normalized) {
|
||||||
|
EditControllerEx1::setParamNormalized(id, normalized);
|
||||||
|
if (!componentHandler) return;
|
||||||
|
// A drag holds its own begin/end across the whole gesture so a host in touch or latch mode
|
||||||
|
// sees one continuous edit; every other writer — a reset, an envelope-node drag, the bake's
|
||||||
|
// reset — emits a degenerate one-point gesture, which is what makes the host DISPLAY follow
|
||||||
|
// it instead of re-imposing the pre-write value on the next touch.
|
||||||
|
const bool inGesture = openGestureId_ == id;
|
||||||
|
if (!inGesture) beginEdit(id);
|
||||||
|
performEdit(id, normalized);
|
||||||
|
if (!inGesture) endEdit(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ReaSamplerProcessor::beginParamGesture(DeckParam deck) {
|
||||||
|
const param::ParamId id = param::paramIdFor(deck);
|
||||||
|
if (id == 0 || !param::isExposed(deck)) return;
|
||||||
|
endParamGesture(); // a grab while one is open cannot leave the previous unclosed
|
||||||
|
openGestureId_ = id;
|
||||||
|
beginEdit(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ReaSamplerProcessor::endParamGesture() {
|
||||||
|
if (openGestureId_ == 0) return;
|
||||||
|
const param::ParamId id = openGestureId_;
|
||||||
|
openGestureId_ = 0; // cleared FIRST: endEdit can re-enter through a host's own callback
|
||||||
|
endEdit(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace reasampler::vst
|
||||||
@@ -41,6 +41,10 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) {
|
|||||||
// calls setupProcessing before setState on load), which the legacy v3 payload's
|
// calls setupProcessing before setState on load), which the legacy v3 payload's
|
||||||
// frames->seconds conversion needs.
|
// frames->seconds conversion needs.
|
||||||
const ComponentState cs = deserializeComponentState(bytes, sampleRate_);
|
const ComponentState cs = deserializeComponentState(bytes, sampleRate_);
|
||||||
|
// A LOAD is not an edit: the SDK is explicit that a controller must never pass a restored
|
||||||
|
// value back to the host through IComponentHandler. The push into the controller happens
|
||||||
|
// once at the tail instead, through syncParamsFromModel.
|
||||||
|
paramNotifySuppressed_ = true;
|
||||||
setSelectedSampleId(cs.selectionId);
|
setSelectedSampleId(cs.selectionId);
|
||||||
setInstrumentParams(cs.params);
|
setInstrumentParams(cs.params);
|
||||||
// Restore the last-consumed assignment generation so a re-open does not re-apply a
|
// Restore the last-consumed assignment generation so a re-open does not re-apply a
|
||||||
@@ -86,6 +90,12 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) {
|
|||||||
// A new blob is new facts — the legacy lift gets one fresh run per restored state.
|
// A new blob is new facts — the legacy lift gets one fresh run per restored state.
|
||||||
legacyLiftConcluded_.store(false, std::memory_order_relaxed);
|
legacyLiftConcluded_.store(false, std::memory_order_relaxed);
|
||||||
reloadInstrument();
|
reloadInstrument();
|
||||||
|
paramNotifySuppressed_ = false;
|
||||||
|
// Every exposed parameter now reads the blob's value. Ordering against the host's first
|
||||||
|
// parameter block is irrelevant BY CONSTRUCTION rather than by assumption: there is one
|
||||||
|
// model and one funnel per control, so whichever of the two writes last simply wins, and
|
||||||
|
// the host's display follows the model either way.
|
||||||
|
syncParamsFromModel();
|
||||||
// This caller has no editor to flush for it. At the TAIL on purpose: a host that services the
|
// This caller has no editor to flush for it. At the TAIL on purpose: a host that services the
|
||||||
// restart synchronously deactivates/reactivates, and our setActive(true) resumes or reloads
|
// restart synchronously deactivates/reactivates, and our setActive(true) resumes or reloads
|
||||||
// against the refs above, which are only fully restored once this function has run to here.
|
// against the refs above, which are only fully restored once this function has run to here.
|
||||||
@@ -153,8 +163,10 @@ InstrumentParams ReaSamplerProcessor::instrumentParams() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void ReaSamplerProcessor::setInstrumentParams(const InstrumentParams& params) {
|
void ReaSamplerProcessor::setInstrumentParams(const InstrumentParams& params) {
|
||||||
|
InstrumentParams before;
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(paramsMutex_);
|
std::lock_guard<std::mutex> lock(paramsMutex_);
|
||||||
|
before = params_;
|
||||||
params_ = params;
|
params_ = params;
|
||||||
}
|
}
|
||||||
// Every writer of the parameter set — setState, the editor's commits, the bake's adopt —
|
// Every writer of the parameter set — setState, the editor's commits, the bake's adopt —
|
||||||
@@ -173,6 +185,10 @@ void ReaSamplerProcessor::setInstrumentParams(const InstrumentParams& params) {
|
|||||||
latencyRestartPending_.store(
|
latencyRestartPending_.store(
|
||||||
params.limiterEnabled != latencyAnnounced_.load(std::memory_order_relaxed),
|
params.limiterEnabled != latencyAnnounced_.load(std::memory_order_relaxed),
|
||||||
std::memory_order_release);
|
std::memory_order_release);
|
||||||
|
// The host-notification obligation, at the same one funnel and for the same reason the
|
||||||
|
// limiter mirror sits here: an internal write that skipped it would leave the host
|
||||||
|
// displaying — and, on the next touch, re-imposing — the superseded value.
|
||||||
|
notifyParamsFromModel(before, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
void ReaSamplerProcessor::flushLatencyRestart() {
|
void ReaSamplerProcessor::flushLatencyRestart() {
|
||||||
@@ -319,7 +335,14 @@ void ReaSamplerProcessor::setMasterGainLinear(double linear) {
|
|||||||
if (!(linear >= 0.0)) linear = 0.0; // also catches NaN
|
if (!(linear >= 0.0)) linear = 0.0; // also catches NaN
|
||||||
const double maxLin = masterGainMaxLinear();
|
const double maxLin = masterGainMaxLinear();
|
||||||
if (linear > maxLin) linear = maxLin;
|
if (linear > maxLin) linear = maxLin;
|
||||||
masterGain_.store(static_cast<float>(linear), std::memory_order_relaxed);
|
const float value = static_cast<float>(linear);
|
||||||
|
const float previous = masterGain_.exchange(value, std::memory_order_relaxed);
|
||||||
|
// Gain's own notification funnel — it is the one exposed control that does not ride the
|
||||||
|
// parameter set, so setInstrumentParams' diff cannot see it. Compared for a real change so a
|
||||||
|
// reload's republish of an unmoved gain writes nothing into a host's automation lane.
|
||||||
|
if (paramNotifySuppressed_ || previous == value) return;
|
||||||
|
notifyParamChanged(instrument::param::kParamMasterGain,
|
||||||
|
instrument::engine::masterGainNormFromLinear(linear));
|
||||||
}
|
}
|
||||||
|
|
||||||
void ReaSamplerProcessor::previewNoteOn(int note) {
|
void ReaSamplerProcessor::previewNoteOn(int note) {
|
||||||
|
|||||||
@@ -256,6 +256,13 @@ private:
|
|||||||
// liveCommitFor (deck_groups.h) for why.
|
// liveCommitFor (deck_groups.h) for why.
|
||||||
bool dragCommitsLive(DragKind kind, int paramId = -1) const;
|
bool dragCommitsLive(DragKind kind, int paramId = -1) const;
|
||||||
|
|
||||||
|
// The deck knobs whose value lives on the processor or on the editor rather than in the
|
||||||
|
// parameter set: the preview-velocity sentinel, voice count, master gain. applyDeckKnob
|
||||||
|
// writes each straight through, so params_ is not a rollback target for them and
|
||||||
|
// commitLive has nothing of theirs to push. Every drag path that ends a gesture — release,
|
||||||
|
// reset, capture-lost, per-move — asks this same question.
|
||||||
|
static bool deckKnobIsProcessorSide(int paramId);
|
||||||
|
|
||||||
// Commits `id` as the loaded capture. The one parameter set carries over — it governs
|
// Commits `id` as the loaded capture. The one parameter set carries over — it governs
|
||||||
// whatever is loaded, so a load swaps the sound, not the settings.
|
// whatever is loaded, so a load swaps the sound, not the settings.
|
||||||
void loadSelection(const std::string& id);
|
void loadSelection(const std::string& id);
|
||||||
@@ -398,8 +405,12 @@ private:
|
|||||||
// params edit, no reload).
|
// params edit, no reload).
|
||||||
void applyDeckKnob(int id, double norm);
|
void applyDeckKnob(int id, double norm);
|
||||||
|
|
||||||
// The knob's live value label shown during hover/drag: milliseconds, percents, Hz, signed
|
// The knob's value in the REAL unit the host is told about (core/instrument/param).
|
||||||
// semitones, a curve exponent, a voice count, or the master-gain dB.
|
double deckPlainValue(int id) const;
|
||||||
|
|
||||||
|
// The knob's live value label shown during hover/drag: the ONE per-category formatter's
|
||||||
|
// digits plus this surface's own static chrome (the unit suffix, a curve dial's caret).
|
||||||
|
// Empty for a control with no plain-value layer.
|
||||||
std::string deckValueLabel(int id) const;
|
std::string deckValueLabel(int id) const;
|
||||||
|
|
||||||
ReaSamplerProcessor* processor_ = nullptr;
|
ReaSamplerProcessor* processor_ = nullptr;
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ tresult PLUGIN_API ReaSamplerProcessor::initialize(FUnknown* context) {
|
|||||||
addEventInput(STR16("MIDI In"), 16);
|
addEventInput(STR16("MIDI In"), 16);
|
||||||
addAudioOutput(STR16("Audio Out"), SpeakerArr::kStereo);
|
addAudioOutput(STR16("Audio Out"), SpeakerArr::kStereo);
|
||||||
|
|
||||||
|
buildParameterList();
|
||||||
|
|
||||||
return kResultOk;
|
return kResultOk;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
#include "core/instrument/engine/live_params.h" // LiveParams (the live-parameter block)
|
#include "core/instrument/engine/live_params.h" // LiveParams (the live-parameter block)
|
||||||
#include "core/instrument/engine/meter_accumulate.h" // the meter's block-rate folds + consume
|
#include "core/instrument/engine/meter_accumulate.h" // the meter's block-rate folds + consume
|
||||||
#include "core/instrument/engine/voice_engine.h"
|
#include "core/instrument/engine/voice_engine.h"
|
||||||
|
#include "core/instrument/param/param_id.h" // the frozen ParamId space + DeckParam binding
|
||||||
|
|
||||||
namespace reasampler::vst {
|
namespace reasampler::vst {
|
||||||
|
|
||||||
@@ -124,6 +125,31 @@ public:
|
|||||||
// Hands the host our LICE IPlugView editor.
|
// Hands the host our LICE IPlugView editor.
|
||||||
Steinberg::IPlugView* PLUGIN_API createView(Steinberg::FIDString name) override;
|
Steinberg::IPlugView* PLUGIN_API createView(Steinberg::FIDString name) override;
|
||||||
|
|
||||||
|
// A host write of one exposed parameter. Applies it to THE model through that control's
|
||||||
|
// existing commit tier — live publish, or the master-gain atomic — and never through a
|
||||||
|
// fourth route. Nothing reachable from here touches reloadInstrument or rebuildVoiceEngine,
|
||||||
|
// which is structural rather than careful: every reload- and rebuild-tier control is omitted
|
||||||
|
// from the parameter list, so no id maps to one.
|
||||||
|
//
|
||||||
|
// Parameter values reach the audio thread through the SAME block the editor's knobs publish
|
||||||
|
// into, which the engine observes ONCE per render() — i.e. at BLOCK BOUNDARIES, last write
|
||||||
|
// wins for that block. Sample-accurate application would put a per-sample "did anything
|
||||||
|
// change" question on the per-voice-per-sample path, which the phase-wide guardrail forbids.
|
||||||
|
// process() reads no parameter queue and is unchanged by the parameter surface.
|
||||||
|
Steinberg::tresult PLUGIN_API setParamNormalized(
|
||||||
|
Steinberg::Vst::ParamID tag, Steinberg::Vst::ParamValue value) override;
|
||||||
|
|
||||||
|
// Pushes every exposed parameter's normalized value from the model into the controller
|
||||||
|
// WITHOUT notifying the host — the load direction, which the SDK forbids reflecting back
|
||||||
|
// through IComponentHandler. UI/main thread.
|
||||||
|
void syncParamsFromModel();
|
||||||
|
|
||||||
|
// A knob drag's host-edit bracket, so a host in touch or latch mode records ONE continuous
|
||||||
|
// edit rather than a burst of one-point gestures. Idempotent: a grab while one is open
|
||||||
|
// closes it first, and endParamGesture with none open does nothing. UI thread only.
|
||||||
|
void beginParamGesture(instrument::ui::DeckParam deck);
|
||||||
|
void endParamGesture();
|
||||||
|
|
||||||
// Additionally exposes REAPER's IReaperUIEmbedInterface (queried by REAPER to drive the
|
// Additionally exposes REAPER's IReaperUIEmbedInterface (queried by REAPER to drive the
|
||||||
// inline TCP/MCP embed); all other iids delegate to SingleComponentEffect unchanged.
|
// inline TCP/MCP embed); all other iids delegate to SingleComponentEffect unchanged.
|
||||||
Steinberg::tresult PLUGIN_API queryInterface(const Steinberg::TUID iid,
|
Steinberg::tresult PLUGIN_API queryInterface(const Steinberg::TUID iid,
|
||||||
@@ -276,6 +302,23 @@ public:
|
|||||||
std::string usageInstanceGuid();
|
std::string usageInstanceGuid();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
// Builds the unit and parameter lists from the derived exposed set. Called once, from
|
||||||
|
// initialize().
|
||||||
|
void buildParameterList();
|
||||||
|
|
||||||
|
// The normalized value a control reads at, from the model — the projection §6.1 calls a
|
||||||
|
// third surface. Master gain reads the processor's own atomic; everything else reads the
|
||||||
|
// parameter set through the deck's binding.
|
||||||
|
double modelParamNormalized(const InstrumentParams& params,
|
||||||
|
instrument::ui::DeckParam deck) const;
|
||||||
|
|
||||||
|
// Notifies the host of every exposed control whose value differs between the two parameter
|
||||||
|
// sets. Called from setInstrumentParams — the ONE funnel every writer already goes through —
|
||||||
|
// so no internal write can leave the host displaying, and on next touch re-imposing, a
|
||||||
|
// superseded value. The bake's reset is the first non-gesture writer this covers.
|
||||||
|
void notifyParamsFromModel(const InstrumentParams& before, const InstrumentParams& after);
|
||||||
|
void notifyParamChanged(instrument::param::ParamId id, double normalized);
|
||||||
|
|
||||||
// If process() published that the drain instrument is fully idle, move it into the
|
// If process() published that the drain instrument is fully idle, move it into the
|
||||||
// graveyard and prune — so an edited-away snapshot stops costing memory as soon as its
|
// graveyard and prune — so an edited-away snapshot stops costing memory as soon as its
|
||||||
// tails die. Off the audio thread only (driven by pollBankSync); safe against a racing
|
// tails die. Off the audio thread only (driven by pollBankSync); safe against a racing
|
||||||
@@ -333,6 +376,12 @@ private:
|
|||||||
|
|
||||||
ReaperBridge bridge_;
|
ReaperBridge bridge_;
|
||||||
|
|
||||||
|
// The parameter whose drag bracket is currently open, 0 for none. UI thread only.
|
||||||
|
instrument::param::ParamId openGestureId_ = 0;
|
||||||
|
// Set across setState so a LOAD is not reflected back to the host as an edit. Main thread
|
||||||
|
// only, and non-atomic on purpose: the SDK calls setState there and nowhere else.
|
||||||
|
bool paramNotifySuppressed_ = false;
|
||||||
|
|
||||||
// The ONE live-parameter block for this instance, declared ahead of the instrument slots
|
// The ONE live-parameter block for this instance, declared ahead of the instrument slots
|
||||||
// so it outlives every snapshot that points at it (members destruct in reverse order).
|
// so it outlives every snapshot that points at it (members destruct in reverse order).
|
||||||
// Both live_ and draining_ observe this same block — a block owned by a snapshot would
|
// Both live_ and draining_ observe this same block — a block owned by a snapshot would
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ static void testEveryDeckControlIsClassifiedIntoOneOfTheThreeCommitTiers() {
|
|||||||
DeckParam::kFilterEnvAttackCurve, DeckParam::kFilterEnvDecayCurve,
|
DeckParam::kFilterEnvAttackCurve, DeckParam::kFilterEnvDecayCurve,
|
||||||
DeckParam::kFilterEnvReleaseCurve,
|
DeckParam::kFilterEnvReleaseCurve,
|
||||||
DeckParam::kFilterTrigAttackCurve, DeckParam::kFilterTrigDecayCurve,
|
DeckParam::kFilterTrigAttackCurve, DeckParam::kFilterTrigDecayCurve,
|
||||||
|
// The one live control that does not ride the live block: a lock-free atomic 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".
|
||||||
|
DeckParam::kMasterGain,
|
||||||
};
|
};
|
||||||
for (DeckParam p : live) CHECK(deckParamCommit(p) == LiveCommit::Live);
|
for (DeckParam p : live) CHECK(deckParamCommit(p) == LiveCommit::Live);
|
||||||
|
|
||||||
@@ -60,7 +64,7 @@ static void testEveryDeckControlIsClassifiedIntoOneOfTheThreeCommitTiers() {
|
|||||||
DeckParam::kAmpEnvSelect, DeckParam::kPitchEnvSelect, DeckParam::kFilterEnvSelect,
|
DeckParam::kAmpEnvSelect, DeckParam::kPitchEnvSelect, DeckParam::kFilterEnvSelect,
|
||||||
DeckParam::kAmpEnvMode, DeckParam::kPitchEnvMode, DeckParam::kFilterEnvMode,
|
DeckParam::kAmpEnvMode, DeckParam::kPitchEnvMode, DeckParam::kFilterEnvMode,
|
||||||
DeckParam::kVoiceCount, DeckParam::kVoiceMode,
|
DeckParam::kVoiceCount, DeckParam::kVoiceMode,
|
||||||
DeckParam::kMonoTrigger, DeckParam::kMasterGain, DeckParam::kLimiterEnable,
|
DeckParam::kMonoTrigger, DeckParam::kLimiterEnable,
|
||||||
DeckParam::kMasterMeter, DeckParam::kMasterGr,
|
DeckParam::kMasterMeter, DeckParam::kMasterGr,
|
||||||
};
|
};
|
||||||
for (DeckParam p : reloads) CHECK(deckParamCommit(p) == LiveCommit::Reload);
|
for (DeckParam p : reloads) CHECK(deckParamCommit(p) == LiveCommit::Reload);
|
||||||
@@ -96,7 +100,10 @@ static void testOnlyALiveControlsDragTakesTheLiveTier() {
|
|||||||
// it move a sounding note) nor as Reload (which would re-decode the WAV under a swept knob).
|
// it move a sounding note) nor as Reload (which would re-decode the WAV under a swept knob).
|
||||||
CHECK(knob(DeckParam::kRate) == LiveCommit::NoteOnLatched);
|
CHECK(knob(DeckParam::kRate) == LiveCommit::NoteOnLatched);
|
||||||
CHECK(knob(DeckParam::kTrigLength) == LiveCommit::Reload);
|
CHECK(knob(DeckParam::kTrigLength) == LiveCommit::Reload);
|
||||||
CHECK(knob(DeckParam::kMasterGain) == LiveCommit::Reload);
|
// Master gain is Live and reaches the audio BESIDE the live block rather than through it —
|
||||||
|
// one atomic the audio thread applies as a post-sum multiply. Classifying it Reload would
|
||||||
|
// claim a gain move re-decodes the WAV, which it never did.
|
||||||
|
CHECK(knob(DeckParam::kMasterGain) == LiveCommit::Live);
|
||||||
CHECK(knob(DeckParam::kAmpEnvSelect) == LiveCommit::Reload);
|
CHECK(knob(DeckParam::kAmpEnvSelect) == LiveCommit::Reload);
|
||||||
// The shell's processor-side sentinels (preview velocity is -2) and any out-of-range id
|
// The shell's processor-side sentinels (preview velocity is -2) and any out-of-range id
|
||||||
// are not parameter-set controls, so they must never reach the enum.
|
// are not parameter-set controls, so they must never reach the enum.
|
||||||
|
|||||||
@@ -20,12 +20,6 @@ static int g_fail = 0;
|
|||||||
#define CHECK(cond) do { if(!(cond)) { \
|
#define CHECK(cond) do { if(!(cond)) { \
|
||||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||||
|
|
||||||
static std::string msLabel(double seconds) {
|
|
||||||
char buf[24];
|
|
||||||
formatEnvTimeMs(seconds, buf, sizeof(buf));
|
|
||||||
return std::string(buf);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The stage-time ceiling has TWO names — the overlay's schematic domain and the knob's — and they
|
// The stage-time ceiling has TWO names — the overlay's schematic domain and the knob's — and they
|
||||||
// must be the same number or a maxed knob stops landing on the canvas edge. Asserted, not assumed.
|
// must be the same number or a maxed knob stops landing on the canvas edge. Asserted, not assumed.
|
||||||
static void testTheTwoCeilingNamesAreOneNumber() {
|
static void testTheTwoCeilingNamesAreOneNumber() {
|
||||||
@@ -406,28 +400,6 @@ static void testTheFilterFourKeepTheirIdentityTaper() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// One unit, everywhere, across the formatter's whole range: a sub-millisecond value keeps a
|
|
||||||
// decimal rather than reading as a bare zero, and a multi-second one stays in ms rather than
|
|
||||||
// switching units mid-deck.
|
|
||||||
static void testTimeConstantsAlwaysReadInMilliseconds() {
|
|
||||||
CHECK(msLabel(0.0) == "0.0 ms");
|
|
||||||
CHECK(msLabel(0.0005) == "0.5 ms"); // sub-millisecond
|
|
||||||
CHECK(msLabel(0.0094) == "9.4 ms");
|
|
||||||
CHECK(msLabel(0.012) == "12 ms"); // the use case's own reading
|
|
||||||
CHECK(msLabel(0.25) == "250 ms");
|
|
||||||
CHECK(msLabel(1.5) == "1500 ms"); // multi-second, still ms
|
|
||||||
CHECK(msLabel(kEnvTimeMaxSeconds) == "10000 ms");
|
|
||||||
// The 10 ms hinge belongs to the integer form, not the decimal one.
|
|
||||||
CHECK(msLabel(0.01) == "10 ms");
|
|
||||||
CHECK(msLabel(0.0099) == "9.9 ms");
|
|
||||||
|
|
||||||
// Never overruns a short buffer, and always terminates.
|
|
||||||
char tiny[4];
|
|
||||||
std::memset(tiny, 'x', sizeof(tiny));
|
|
||||||
formatEnvTimeMs(1.5, tiny, sizeof(tiny));
|
|
||||||
CHECK(tiny[3] == '\0');
|
|
||||||
}
|
|
||||||
|
|
||||||
int main() {
|
int main() {
|
||||||
testTheTwoCeilingNamesAreOneNumber();
|
testTheTwoCeilingNamesAreOneNumber();
|
||||||
testNormRoundTripsThroughEveryValueDomain();
|
testNormRoundTripsThroughEveryValueDomain();
|
||||||
@@ -442,7 +414,6 @@ int main() {
|
|||||||
testShiftSnapsToAWholeUnitOfTheDisplayedValue();
|
testShiftSnapsToAWholeUnitOfTheDisplayedValue();
|
||||||
testAValueStoredUnderTheOldCeilingIsReadNotRewritten();
|
testAValueStoredUnderTheOldCeilingIsReadNotRewritten();
|
||||||
testTheFilterFourKeepTheirIdentityTaper();
|
testTheFilterFourKeepTheirIdentityTaper();
|
||||||
testTimeConstantsAlwaysReadInMilliseconds();
|
|
||||||
if (g_fail) {
|
if (g_fail) {
|
||||||
std::printf("%d FAILURE(S)\n", g_fail);
|
std::printf("%d FAILURE(S)\n", g_fail);
|
||||||
return 1;
|
return 1;
|
||||||
|
|||||||
@@ -90,18 +90,6 @@ static void testBelowFloorCollapsesToBottom() {
|
|||||||
CHECK(masterGainNormFromDb(kMasterGainMinDb + 1e-9) > 0.0);
|
CHECK(masterGainNormFromDb(kMasterGainMinDb + 1e-9) > 0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
static void testLabels() {
|
|
||||||
char buf[24];
|
|
||||||
formatMasterGainLabel(0.0, buf, sizeof(buf));
|
|
||||||
CHECK(std::strcmp(buf, "-inf") == 0);
|
|
||||||
formatMasterGainLabel(1.0, buf, sizeof(buf));
|
|
||||||
CHECK(std::strcmp(buf, "+24.0dB") == 0);
|
|
||||||
formatMasterGainLabel(masterGainNormFromDb(0.0), buf, sizeof(buf));
|
|
||||||
CHECK(std::strcmp(buf, "+0.0dB") == 0);
|
|
||||||
formatMasterGainLabel(masterGainNormFromDb(-12.0), buf, sizeof(buf));
|
|
||||||
CHECK(std::strcmp(buf, "-12.0dB") == 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
int main() {
|
int main() {
|
||||||
testBottomIsTrueSilence();
|
testBottomIsTrueSilence();
|
||||||
testEndpoints();
|
testEndpoints();
|
||||||
@@ -110,7 +98,6 @@ int main() {
|
|||||||
testMonotonic();
|
testMonotonic();
|
||||||
testNonFiniteLinearClamps();
|
testNonFiniteLinearClamps();
|
||||||
testBelowFloorCollapsesToBottom();
|
testBelowFloorCollapsesToBottom();
|
||||||
testLabels();
|
|
||||||
if (g_fail) {
|
if (g_fail) {
|
||||||
std::printf("%d FAILURE(S)\n", g_fail);
|
std::printf("%d FAILURE(S)\n", g_fail);
|
||||||
return 1;
|
return 1;
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
// Standalone tests for the ONE formatter per unit category: the digit shapes each category
|
||||||
|
// prints, and the property that makes the editor's knob label and the host's parameter string
|
||||||
|
// identical — both call THIS function, over a plain value derived the way each surface derives
|
||||||
|
// it. No VST3, no REAPER, no framework.
|
||||||
|
|
||||||
|
#include "../src/core/instrument/param/param_format.h"
|
||||||
|
|
||||||
|
#include "../src/core/instrument/engine/master_gain.h"
|
||||||
|
#include "../src/core/instrument/param/param_id.h"
|
||||||
|
#include "../src/core/instrument/ui/deck_values.h"
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
using namespace reasampler;
|
||||||
|
using namespace reasampler::instrument::param;
|
||||||
|
using reasampler::instrument::map::PlaySeconds;
|
||||||
|
using reasampler::instrument::ui::DeckParam;
|
||||||
|
|
||||||
|
static int g_fail = 0;
|
||||||
|
#define CHECK(cond) do { if(!(cond)) { \
|
||||||
|
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||||
|
|
||||||
|
static std::string digits(UnitKind kind, double plain) {
|
||||||
|
char buf[24];
|
||||||
|
formatPlain(kind, plain, buf, sizeof(buf));
|
||||||
|
return std::string(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testEachCategoryPrintsItsSpecifiedShape() {
|
||||||
|
// A time constant never switches to seconds, so the ceiling reads 10000 and not 10.
|
||||||
|
CHECK(digits(UnitKind::Time, 0.5) == "0.5");
|
||||||
|
CHECK(digits(UnitKind::Time, 3.0) == "3.0");
|
||||||
|
CHECK(digits(UnitKind::Time, 10.0) == "10");
|
||||||
|
CHECK(digits(UnitKind::Time, 104.0) == "104");
|
||||||
|
CHECK(digits(UnitKind::Time, 10000.0) == "10000");
|
||||||
|
// Semitones are always signed, including at zero — an unsigned "0.0" beside a "+3.5" reads
|
||||||
|
// as a different kind of quantity.
|
||||||
|
CHECK(digits(UnitKind::Semitones, 3.5) == "+3.5");
|
||||||
|
CHECK(digits(UnitKind::Semitones, -12.0) == "-12.0");
|
||||||
|
CHECK(digits(UnitKind::Semitones, 0.0) == "+0.0");
|
||||||
|
CHECK(digits(UnitKind::PercentUnipolar, 100.0) == "100");
|
||||||
|
CHECK(digits(UnitKind::PercentKeyTrack, 200.0) == "200");
|
||||||
|
CHECK(digits(UnitKind::PercentBipolar, -40.0) == "-40");
|
||||||
|
CHECK(digits(UnitKind::PercentBipolar, 40.0) == "+40");
|
||||||
|
// Rate keeps a decimal: its snap grid is whole semitones, which do not land on integer
|
||||||
|
// percent, so an integer display would print a snapped position the snap cannot produce.
|
||||||
|
CHECK(digits(UnitKind::PercentRate, 105.946) == "105.9");
|
||||||
|
CHECK(digits(UnitKind::PercentRate, 200.0) == "200.0");
|
||||||
|
CHECK(digits(UnitKind::Decibels, 0.0) == "+0.0");
|
||||||
|
CHECK(digits(UnitKind::Decibels, -12.0) == "-12.0");
|
||||||
|
CHECK(digits(UnitKind::Decibels, -std::numeric_limits<double>::infinity()) == "-inf");
|
||||||
|
// Cutoff's "k" abbreviation is retired — one static units string cannot switch with
|
||||||
|
// magnitude, and keeping "12.8k" on one surface alone is the divergence this file forbids.
|
||||||
|
CHECK(digits(UnitKind::Hertz, 240.0) == "240");
|
||||||
|
CHECK(digits(UnitKind::Hertz, 12800.0) == "12800");
|
||||||
|
// The caret on a curve dial is the editor's static cell chrome, never part of the value.
|
||||||
|
CHECK(digits(UnitKind::Dimensionless, 1.0) == "1.00");
|
||||||
|
CHECK(digits(UnitKind::Dimensionless, 0.1) == "0.10");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testTheEditorAndTheHostPrintTheSameDigitsAtTheSameStoredValue() {
|
||||||
|
// The host derives its plain value from the normalized one it holds; the editor derives its
|
||||||
|
// from the STORED field, through the deck's own read. If those two derivations disagreed at
|
||||||
|
// any reachable value the two surfaces would print different numbers for one control — this
|
||||||
|
// is that property, swept over the whole travel of every exposed control.
|
||||||
|
for (const ParamRow& row : exposedParams()) {
|
||||||
|
if (row.deck == DeckParam::kMasterGain) continue; // not stored in PlaySeconds
|
||||||
|
for (int step = 0; step <= 40; ++step) {
|
||||||
|
const double norm = step / 40.0;
|
||||||
|
PlaySeconds play;
|
||||||
|
reasampler::instrument::ui::setDeckParam(row.deck, play, norm, /*segment=*/0);
|
||||||
|
|
||||||
|
char hostBuf[24];
|
||||||
|
formatPlainFor(row.deck, toPlain(row.deck, norm), hostBuf, sizeof(hostBuf));
|
||||||
|
|
||||||
|
const double editorNorm =
|
||||||
|
reasampler::instrument::ui::deckParamNorm(row.deck, play);
|
||||||
|
char editorBuf[24];
|
||||||
|
formatPlainFor(row.deck, toPlain(row.deck, editorNorm), editorBuf, sizeof(editorBuf));
|
||||||
|
|
||||||
|
if (storesNormalized(row.deck)) {
|
||||||
|
// The filter's four store their position as a FLOAT, so a norm the host has sent
|
||||||
|
// but we have not yet stored differs from the stored one by up to a float ulp.
|
||||||
|
// At a value landing exactly on a display rounding boundary that is worth one
|
||||||
|
// digit, so these four are held to the PLAIN value rather than to the string —
|
||||||
|
// the derivation is still asserted to be one derivation.
|
||||||
|
const double hostPlain = toPlain(row.deck, norm);
|
||||||
|
const double editorPlain = toPlain(row.deck, editorNorm);
|
||||||
|
const double tolerance = std::fabs(hostPlain) * 1e-6 + 1e-9;
|
||||||
|
if (std::fabs(hostPlain - editorPlain) > tolerance) {
|
||||||
|
std::printf("FAIL param %u at norm %.4f: host %.9g vs editor %.9g\n",
|
||||||
|
row.id, norm, hostPlain, editorPlain);
|
||||||
|
++g_fail;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (std::strcmp(hostBuf, editorBuf) != 0) {
|
||||||
|
std::printf("FAIL param %u at norm %.4f: host \"%s\" vs editor \"%s\"\n",
|
||||||
|
row.id, norm, hostBuf, editorBuf);
|
||||||
|
++g_fail;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testMasterGainPrintsTheSameDigitsFromEitherSurface() {
|
||||||
|
using reasampler::instrument::engine::masterGainLinearFromNorm;
|
||||||
|
using reasampler::instrument::engine::masterGainNormFromLinear;
|
||||||
|
for (int step = 0; step <= 40; ++step) {
|
||||||
|
const double norm = step / 40.0;
|
||||||
|
// The editor reads the processor's stored LINEAR gain back through the taper; the host
|
||||||
|
// holds the normalized value directly.
|
||||||
|
const double editorNorm = masterGainNormFromLinear(masterGainLinearFromNorm(norm));
|
||||||
|
char hostBuf[24];
|
||||||
|
char editorBuf[24];
|
||||||
|
formatPlainFor(DeckParam::kMasterGain, toPlain(DeckParam::kMasterGain, norm),
|
||||||
|
hostBuf, sizeof(hostBuf));
|
||||||
|
formatPlainFor(DeckParam::kMasterGain, toPlain(DeckParam::kMasterGain, editorNorm),
|
||||||
|
editorBuf, sizeof(editorBuf));
|
||||||
|
if (std::strcmp(hostBuf, editorBuf) != 0) {
|
||||||
|
std::printf("FAIL master gain at norm %.4f: host \"%s\" vs editor \"%s\"\n",
|
||||||
|
norm, hostBuf, editorBuf);
|
||||||
|
++g_fail;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testTypingBackADisplayedValueLandsOnIt() {
|
||||||
|
// getParamValueByString's half: the digits the host just showed must parse to the same
|
||||||
|
// plain value, with or without the unit a user may retype beside them.
|
||||||
|
double plain = 0.0;
|
||||||
|
CHECK(parsePlain(UnitKind::Time, "104", plain) && plain == 104.0);
|
||||||
|
CHECK(parsePlain(UnitKind::Time, "104 ms", plain) && plain == 104.0);
|
||||||
|
CHECK(parsePlain(UnitKind::Semitones, "+3.5", plain) && plain == 3.5);
|
||||||
|
CHECK(parsePlain(UnitKind::Semitones, "-12.0st", plain) && plain == -12.0);
|
||||||
|
CHECK(parsePlain(UnitKind::Hertz, "12800Hz", plain) && plain == 12800.0);
|
||||||
|
CHECK(parsePlain(UnitKind::Decibels, "-inf", plain) && !std::isfinite(plain) && plain < 0.0);
|
||||||
|
CHECK(!parsePlain(UnitKind::Time, "abc", plain));
|
||||||
|
CHECK(!parsePlain(UnitKind::Time, nullptr, plain));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testAShortBufferIsNeverOverrunAndAlwaysTerminates() {
|
||||||
|
for (int kind = 0; kind <= static_cast<int>(UnitKind::Dimensionless); ++kind) {
|
||||||
|
char tiny[4];
|
||||||
|
std::memset(tiny, 'x', sizeof(tiny));
|
||||||
|
formatPlain(static_cast<UnitKind>(kind), 1500.0, tiny, sizeof(tiny));
|
||||||
|
CHECK(tiny[3] == '\0');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testEveryExposedParameterHasAFormatterThatWritesSomething() {
|
||||||
|
for (const ParamRow& row : exposedParams()) {
|
||||||
|
char buf[24];
|
||||||
|
formatPlainFor(row.deck, toPlain(row.deck, 0.5), buf, sizeof(buf));
|
||||||
|
if (buf[0] == '\0') {
|
||||||
|
std::printf("FAIL param %u produced an empty string\n", row.id);
|
||||||
|
++g_fail;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
testEachCategoryPrintsItsSpecifiedShape();
|
||||||
|
testTheEditorAndTheHostPrintTheSameDigitsAtTheSameStoredValue();
|
||||||
|
testMasterGainPrintsTheSameDigitsFromEitherSurface();
|
||||||
|
testTypingBackADisplayedValueLandsOnIt();
|
||||||
|
testAShortBufferIsNeverOverrunAndAlwaysTerminates();
|
||||||
|
testEveryExposedParameterHasAFormatterThatWritesSomething();
|
||||||
|
if (g_fail == 0) std::printf("param_format: all tests passed\n");
|
||||||
|
return g_fail == 0 ? 0 : 1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
// Standalone tests for the FOREVER-FROZEN VST3 parameter id table and the exposed set derived
|
||||||
|
// from the commit predicate — no VST3, no REAPER, no framework.
|
||||||
|
//
|
||||||
|
// The asserted numbers are LITERALS on purpose. A test that recomputed them from cellIds, from
|
||||||
|
// the enum's position, or from the table itself would defeat the freeze it exists to hold: the
|
||||||
|
// point is that changing any id has to break this file.
|
||||||
|
|
||||||
|
#include "../src/core/instrument/param/param_id.h"
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <set>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
using namespace reasampler;
|
||||||
|
using namespace reasampler::instrument::param;
|
||||||
|
using reasampler::instrument::ui::DeckParam;
|
||||||
|
using reasampler::instrument::ui::LiveCommit;
|
||||||
|
using reasampler::instrument::ui::deckParamCommit;
|
||||||
|
|
||||||
|
static int g_fail = 0;
|
||||||
|
#define CHECK(cond) do { if(!(cond)) { \
|
||||||
|
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||||
|
|
||||||
|
static void testEveryIdHoldsTheNumberItShippedWith() {
|
||||||
|
// docs/product/parameter-automation.md 6.2, transcribed. If this list and the table
|
||||||
|
// disagree, the table moved and every automation lane recorded against it now means
|
||||||
|
// something else.
|
||||||
|
struct Expect { ParamId id; DeckParam deck; };
|
||||||
|
const Expect kExpected[] = {
|
||||||
|
{1000, DeckParam::kKeyTrack},
|
||||||
|
{1010, DeckParam::kRate},
|
||||||
|
{1020, DeckParam::kPitch},
|
||||||
|
{1100, DeckParam::kPitchEnvAttack},
|
||||||
|
{1101, DeckParam::kPitchEnvAttackCurve},
|
||||||
|
{1110, DeckParam::kPitchEnvHold},
|
||||||
|
{1120, DeckParam::kPitchEnvDecay},
|
||||||
|
{1121, DeckParam::kPitchEnvDecayCurve},
|
||||||
|
{1130, DeckParam::kPitchEnvDepth},
|
||||||
|
{1200, DeckParam::kFilterMorph},
|
||||||
|
{1210, DeckParam::kFilterCutoff},
|
||||||
|
{1220, DeckParam::kFilterQ},
|
||||||
|
{1230, DeckParam::kFilterDrive},
|
||||||
|
{1240, DeckParam::kFilterModAmt},
|
||||||
|
{1250, DeckParam::kFilterVel},
|
||||||
|
{1260, DeckParam::kFilterKeyTrack},
|
||||||
|
{1300, DeckParam::kFilterEnvAttack},
|
||||||
|
{1301, DeckParam::kFilterEnvAttackCurve},
|
||||||
|
{1310, DeckParam::kFilterEnvHold},
|
||||||
|
{1320, DeckParam::kFilterEnvDecay},
|
||||||
|
{1321, DeckParam::kFilterEnvDecayCurve},
|
||||||
|
{1330, DeckParam::kFilterEnvSustain},
|
||||||
|
{1340, DeckParam::kFilterEnvRelease},
|
||||||
|
{1341, DeckParam::kFilterEnvReleaseCurve},
|
||||||
|
{1350, DeckParam::kFilterTrigAttack},
|
||||||
|
{1351, DeckParam::kFilterTrigAttackCurve},
|
||||||
|
{1360, DeckParam::kFilterTrigHold},
|
||||||
|
{1370, DeckParam::kFilterTrigDecay},
|
||||||
|
{1371, DeckParam::kFilterTrigDecayCurve},
|
||||||
|
{1400, DeckParam::kAttack},
|
||||||
|
{1401, DeckParam::kAttackCurve},
|
||||||
|
{1410, DeckParam::kHold},
|
||||||
|
{1420, DeckParam::kDecay},
|
||||||
|
{1421, DeckParam::kDecayCurve},
|
||||||
|
{1430, DeckParam::kSustain},
|
||||||
|
{1440, DeckParam::kRelease},
|
||||||
|
{1441, DeckParam::kReleaseCurve},
|
||||||
|
{1450, DeckParam::kTrigLength},
|
||||||
|
{1460, DeckParam::kTrigAttack},
|
||||||
|
{1461, DeckParam::kTrigAttackCurve},
|
||||||
|
{1470, DeckParam::kTrigHold},
|
||||||
|
{1480, DeckParam::kTrigDecay},
|
||||||
|
{1481, DeckParam::kTrigDecayCurve},
|
||||||
|
{1700, DeckParam::kMasterGain},
|
||||||
|
};
|
||||||
|
const std::size_t expectedCount = sizeof(kExpected) / sizeof(kExpected[0]);
|
||||||
|
CHECK(expectedCount == 44);
|
||||||
|
CHECK(paramTable().size() == expectedCount);
|
||||||
|
if (paramTable().size() != expectedCount) return;
|
||||||
|
for (std::size_t i = 0; i < expectedCount; ++i) {
|
||||||
|
CHECK(paramTable()[i].id == kExpected[i].id);
|
||||||
|
CHECK(paramTable()[i].deck == kExpected[i].deck);
|
||||||
|
CHECK(paramIdFor(kExpected[i].deck) == kExpected[i].id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testIdsAreUniqueAscendingInBlockAndOnStep() {
|
||||||
|
std::set<ParamId> seen;
|
||||||
|
ParamId previous = 0;
|
||||||
|
for (const ParamRow& row : paramTable()) {
|
||||||
|
CHECK(seen.insert(row.id).second); // unique
|
||||||
|
CHECK(row.id > previous); // ascending == presentation order
|
||||||
|
previous = row.id;
|
||||||
|
CHECK(row.id >= 1000); // 0 is a plausible accident, never an id
|
||||||
|
const ParamId withinBlock = row.id % 100;
|
||||||
|
// On the step, or one past it — a curve dial takes its outer knob's id + 1, and only a
|
||||||
|
// curve dial may.
|
||||||
|
const bool onStep = withinBlock % 10 == 0;
|
||||||
|
const bool innerDial = withinBlock % 10 == 1;
|
||||||
|
CHECK(onStep || innerDial);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testAnInnerDialSitsBesideTheKnobItShapes() {
|
||||||
|
for (const ParamRow& row : paramTable()) {
|
||||||
|
if (row.id % 10 != 1) continue;
|
||||||
|
// Its outer knob is the row numbered one below it, and curveParamFor must agree that the
|
||||||
|
// dial belongs to that knob.
|
||||||
|
const ParamRow* outer = nullptr;
|
||||||
|
for (const ParamRow& candidate : paramTable()) {
|
||||||
|
if (candidate.id == row.id - 1) outer = &candidate;
|
||||||
|
}
|
||||||
|
CHECK(outer != nullptr);
|
||||||
|
if (outer) CHECK(reasampler::instrument::ui::curveParamFor(outer->deck) == row.deck);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testABlockCarriesOnlyItsOwnGroup() {
|
||||||
|
for (const ParamRow& row : paramTable()) {
|
||||||
|
const ParamId block = row.id / 100 * 100;
|
||||||
|
switch (block) {
|
||||||
|
case 1000: CHECK(row.unit == kUnitPitch); break;
|
||||||
|
case 1100: CHECK(row.unit == kUnitPitchEnv); break;
|
||||||
|
case 1200: CHECK(row.unit == kUnitFilter); break;
|
||||||
|
case 1300: CHECK(row.unit == kUnitFilterEnv); break;
|
||||||
|
case 1400: CHECK(row.unit == kUnitAmp); break;
|
||||||
|
case 1700: CHECK(row.unit == kUnitMaster); break;
|
||||||
|
default: CHECK(false); break; // 1500/1600 are reserved and must stay empty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testTheExposedSetIsExactlyThePredicateAnswer() {
|
||||||
|
// Asserted against deckParamCommit, never against a literal count: the list follows the
|
||||||
|
// predicate, and the predicate is never bent to fill the list.
|
||||||
|
for (const ParamRow& row : paramTable()) {
|
||||||
|
const bool live = deckParamCommit(row.deck) != LiveCommit::Reload;
|
||||||
|
CHECK(isExposed(row.deck) == live);
|
||||||
|
const bool listed = exposedRowFor(row.id) != nullptr;
|
||||||
|
CHECK(listed == live);
|
||||||
|
}
|
||||||
|
for (const ParamRow& row : exposedParams()) {
|
||||||
|
CHECK(deckParamCommit(row.deck) != LiveCommit::Reload);
|
||||||
|
}
|
||||||
|
// Every control the predicate calls live must HAVE a row — a live control with no number is
|
||||||
|
// a parameter the host can never be told about.
|
||||||
|
for (int i = 0; i < static_cast<int>(DeckParam::kCount); ++i) {
|
||||||
|
const auto deck = static_cast<DeckParam>(i);
|
||||||
|
if (deckParamCommit(deck) == LiveCommit::Reload) continue;
|
||||||
|
CHECK(paramIdFor(deck) != 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testAReservedRowIsNumberedButNotIssued() {
|
||||||
|
// Key-track (pitch) and Trigger length are note-on-latch candidates that still route through
|
||||||
|
// the reload tier, so they are NOT issued to the host today. Their numbers stay reserved
|
||||||
|
// rather than retired: nothing shipped under them, so a later promotion issues the same id
|
||||||
|
// and no other id moves.
|
||||||
|
CHECK(paramIdFor(DeckParam::kKeyTrack) == 1000);
|
||||||
|
CHECK(paramIdFor(DeckParam::kTrigLength) == 1450);
|
||||||
|
CHECK(!isExposed(DeckParam::kKeyTrack));
|
||||||
|
CHECK(!isExposed(DeckParam::kTrigLength));
|
||||||
|
CHECK(exposedRowFor(1000) == nullptr);
|
||||||
|
CHECK(exposedRowFor(1450) == nullptr);
|
||||||
|
// Master gain IS issued: one atomic store the audio thread picks up next block is the live
|
||||||
|
// tier by that tier's own definition.
|
||||||
|
CHECK(isExposed(DeckParam::kMasterGain));
|
||||||
|
CHECK(exposedRowFor(1700) != nullptr);
|
||||||
|
CHECK(exposedParams().size() == paramTable().size() - 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testEveryRowCarriesADistinctTitleAndShortTitle() {
|
||||||
|
std::set<std::string> titles;
|
||||||
|
std::set<std::string> shortTitles;
|
||||||
|
for (const ParamRow& row : paramTable()) {
|
||||||
|
CHECK(row.title && row.title[0] != '\0');
|
||||||
|
CHECK(row.shortTitle && row.shortTitle[0] != '\0');
|
||||||
|
CHECK(std::string(row.title) != std::string(row.shortTitle));
|
||||||
|
CHECK(titles.insert(row.title).second);
|
||||||
|
CHECK(shortTitles.insert(row.shortTitle).second);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
testEveryIdHoldsTheNumberItShippedWith();
|
||||||
|
testIdsAreUniqueAscendingInBlockAndOnStep();
|
||||||
|
testAnInnerDialSitsBesideTheKnobItShapes();
|
||||||
|
testABlockCarriesOnlyItsOwnGroup();
|
||||||
|
testTheExposedSetIsExactlyThePredicateAnswer();
|
||||||
|
testAReservedRowIsNumberedButNotIssued();
|
||||||
|
testEveryRowCarriesADistinctTitleAndShortTitle();
|
||||||
|
if (g_fail == 0) std::printf("param_id: all tests passed\n");
|
||||||
|
return g_fail == 0 ? 0 : 1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
// Standalone tests for the plain-value layer: unit strings, plain ranges, monotonicity, the
|
||||||
|
// exact-preimage requirement at every default, and the read-side-only property of the filter's
|
||||||
|
// four — no VST3, no REAPER, no framework.
|
||||||
|
|
||||||
|
#include "../src/core/instrument/param/param_units.h"
|
||||||
|
|
||||||
|
#include "../src/core/instrument/param/param_id.h"
|
||||||
|
#include "../src/core/instrument/engine/filter/filter_params.h"
|
||||||
|
#include "../src/core/instrument/map/play_seconds.h"
|
||||||
|
#include "../src/core/instrument/ui/deck_values.h"
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
using namespace reasampler;
|
||||||
|
using namespace reasampler::instrument::param;
|
||||||
|
using reasampler::instrument::map::PlaySeconds;
|
||||||
|
using reasampler::instrument::ui::DeckParam;
|
||||||
|
|
||||||
|
static int g_fail = 0;
|
||||||
|
#define CHECK(cond) do { if(!(cond)) { \
|
||||||
|
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||||
|
#define CHECK_ID(cond, id) do { if(!(cond)) { \
|
||||||
|
std::printf("FAIL line %d (param %u): %s\n", __LINE__, (id), #cond); ++g_fail; } } while(0)
|
||||||
|
|
||||||
|
static void testEveryUnitStringAndRangeMatchesTheSpecifiedTable() {
|
||||||
|
struct Expect { ParamId id; const char* units; double min; double max; };
|
||||||
|
// docs/product/parameter-automation.md 6.7.1, per parameter rather than per category, so a
|
||||||
|
// control silently reclassified into the wrong category fails here.
|
||||||
|
const Expect kExpected[] = {
|
||||||
|
{kParamRate, "%", 50.0, 200.0},
|
||||||
|
{kParamPitchOffset, "st", -24.0, 24.0},
|
||||||
|
{kParamPitchEnvAttack, "ms", 0.0, 10000.0},
|
||||||
|
{kParamPitchEnvAttackCurve, "", 0.1, 10.0},
|
||||||
|
{kParamPitchEnvHold, "%", 0.0, 100.0},
|
||||||
|
{kParamPitchEnvDecay, "ms", 0.0, 10000.0},
|
||||||
|
{kParamPitchEnvDecayCurve, "", 0.1, 10.0},
|
||||||
|
{kParamPitchEnvDepth, "st", -24.0, 24.0},
|
||||||
|
{kParamFilterMorph, "%", 0.0, 100.0},
|
||||||
|
{kParamFilterCutoff, "Hz", 20.0, 20000.0},
|
||||||
|
{kParamFilterQ, "", 0.1, 10.0},
|
||||||
|
{kParamFilterDrive, "", 0.0, 4.0},
|
||||||
|
{kParamFilterModAmount, "%", -100.0, 100.0},
|
||||||
|
{kParamFilterVelAmount, "%", -100.0, 100.0},
|
||||||
|
{kParamKeyTrackFilter, "%", 0.0, 200.0},
|
||||||
|
{kParamFilterEnvAttack, "ms", 0.0, 10000.0},
|
||||||
|
{kParamFilterEnvAttackCurve, "", 0.1, 10.0},
|
||||||
|
{kParamFilterEnvHold, "ms", 0.0, 10000.0},
|
||||||
|
{kParamFilterEnvDecay, "ms", 0.0, 10000.0},
|
||||||
|
{kParamFilterEnvDecayCurve, "", 0.1, 10.0},
|
||||||
|
{kParamFilterEnvSustain, "%", 0.0, 100.0},
|
||||||
|
{kParamFilterEnvRelease, "ms", 0.0, 10000.0},
|
||||||
|
{kParamFilterEnvReleaseCurve, "", 0.1, 10.0},
|
||||||
|
{kParamFilterTrigAttack, "ms", 0.0, 10000.0},
|
||||||
|
{kParamFilterTrigAttackCurve, "", 0.1, 10.0},
|
||||||
|
{kParamFilterTrigHold, "%", 0.0, 100.0},
|
||||||
|
{kParamFilterTrigDecay, "ms", 0.0, 10000.0},
|
||||||
|
{kParamFilterTrigDecayCurve, "", 0.1, 10.0},
|
||||||
|
{kParamAmpAttack, "ms", 0.0, 10000.0},
|
||||||
|
{kParamAmpAttackCurve, "", 0.1, 10.0},
|
||||||
|
{kParamAmpHold, "ms", 0.0, 10000.0},
|
||||||
|
{kParamAmpDecay, "ms", 0.0, 10000.0},
|
||||||
|
{kParamAmpDecayCurve, "", 0.1, 10.0},
|
||||||
|
{kParamAmpSustain, "%", 0.0, 100.0},
|
||||||
|
{kParamAmpRelease, "ms", 0.0, 10000.0},
|
||||||
|
{kParamAmpReleaseCurve, "", 0.1, 10.0},
|
||||||
|
{kParamAmpTrigAttack, "ms", 0.0, 10000.0},
|
||||||
|
{kParamAmpTrigAttackCurve, "", 0.1, 10.0},
|
||||||
|
{kParamAmpTrigHold, "%", 0.0, 100.0},
|
||||||
|
{kParamAmpTrigDecay, "ms", 0.0, 10000.0},
|
||||||
|
{kParamAmpTrigDecayCurve, "", 0.1, 10.0},
|
||||||
|
{kParamMasterGain, "dB", -60.0, 24.0},
|
||||||
|
};
|
||||||
|
const std::size_t count = sizeof(kExpected) / sizeof(kExpected[0]);
|
||||||
|
// Every exposed parameter is covered, and nothing else is listed.
|
||||||
|
CHECK(count == exposedParams().size());
|
||||||
|
for (const Expect& e : kExpected) {
|
||||||
|
const ParamRow* row = exposedRowFor(e.id);
|
||||||
|
CHECK_ID(row != nullptr, e.id);
|
||||||
|
if (!row) continue;
|
||||||
|
CHECK_ID(std::strcmp(unitStringFor(row->deck), e.units) == 0, e.id);
|
||||||
|
const PlainRange range = plainRangeFor(row->deck);
|
||||||
|
// Relative rather than exact: the filter's three endpoints are float constants, so
|
||||||
|
// 0.1f widened is not the double 0.1. A wrong RANGE — 0.2, or 20 — still fails.
|
||||||
|
CHECK_ID(std::fabs(range.min - e.min) <= std::fabs(e.min) * 1e-6 + 1e-12, e.id);
|
||||||
|
CHECK_ID(std::fabs(range.max - e.max) <= std::fabs(e.max) * 1e-6 + 1e-12, e.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testEveryDefaultHasAnExactNormalizedPreimage() {
|
||||||
|
// A host's reset-to-default arrives as toPlain(defaultNormalizedValue) and there is no
|
||||||
|
// editor-side taper bypass available to it. Exactly equal, not near: a gain landing a hair
|
||||||
|
// off unity is an audible error, and a stage time landing a hair off its default is a value
|
||||||
|
// the user never dialled.
|
||||||
|
for (const ParamRow& row : exposedParams()) {
|
||||||
|
const double norm = defaultNormalized(row.deck);
|
||||||
|
CHECK_ID(norm >= 0.0 && norm <= 1.0, row.id);
|
||||||
|
CHECK_ID(toPlain(row.deck, norm) == defaultPlain(row.deck), row.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testTheFiltersFourTakeTheirStoredNormVerbatim() {
|
||||||
|
// Their stored value IS the normalized one, so no taper may participate in their default:
|
||||||
|
// this fails the moment someone routes them through toNormalized(toPlain(x)).
|
||||||
|
PlaySeconds defaults;
|
||||||
|
const DeckParam kStoredNorm[] = {DeckParam::kFilterMorph, DeckParam::kFilterCutoff,
|
||||||
|
DeckParam::kFilterQ, DeckParam::kFilterDrive};
|
||||||
|
for (DeckParam deck : kStoredNorm) {
|
||||||
|
CHECK(storesNormalized(deck));
|
||||||
|
const float* stored = reasampler::instrument::ui::deckFloatField(deck, defaults);
|
||||||
|
CHECK(stored != nullptr);
|
||||||
|
if (stored) CHECK(defaultNormalized(deck) == static_cast<double>(*stored));
|
||||||
|
}
|
||||||
|
// And nothing else claims to store its norm — a control wrongly in that set would silently
|
||||||
|
// skip the taper on the reset path.
|
||||||
|
for (const ParamRow& row : exposedParams()) {
|
||||||
|
const bool listed = row.deck == DeckParam::kFilterMorph ||
|
||||||
|
row.deck == DeckParam::kFilterCutoff ||
|
||||||
|
row.deck == DeckParam::kFilterQ ||
|
||||||
|
row.deck == DeckParam::kFilterDrive;
|
||||||
|
CHECK_ID(storesNormalized(row.deck) == listed, row.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testToPlainIsMonotoneAcrossTheWholeTravel() {
|
||||||
|
// Monotonicity is required everywhere; round-trip exactness at an arbitrary norm is required
|
||||||
|
// NOWHERE and is deliberately not asserted — no log map delivers it in double, and demanding
|
||||||
|
// it would rule out the taper the range needs.
|
||||||
|
for (const ParamRow& row : exposedParams()) {
|
||||||
|
double previous = toPlain(row.deck, 0.0);
|
||||||
|
for (int step = 1; step <= 200; ++step) {
|
||||||
|
const double plain = toPlain(row.deck, step / 200.0);
|
||||||
|
CHECK_ID(plain >= previous, row.id);
|
||||||
|
previous = plain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testTheEndpointsAreTheDeclaredPlainRange() {
|
||||||
|
for (const ParamRow& row : exposedParams()) {
|
||||||
|
const PlainRange range = plainRangeFor(row.deck);
|
||||||
|
const double top = toPlain(row.deck, 1.0);
|
||||||
|
CHECK_ID(std::fabs(top - range.max) <= std::fabs(range.max) * 1e-6 + 1e-9, row.id);
|
||||||
|
if (row.deck == DeckParam::kMasterGain) {
|
||||||
|
// Norm 0 is TRUE silence, not the -60 dB floor — the one plain value outside the
|
||||||
|
// declared range, and the reason the dB formatter has an -inf case at all.
|
||||||
|
CHECK(!std::isfinite(toPlain(row.deck, 0.0)));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const double bottom = toPlain(row.deck, 0.0);
|
||||||
|
CHECK_ID(std::fabs(bottom - range.min) <= std::fabs(range.min) * 1e-6 + 1e-9, row.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testNoExposedControlIsDiscrete() {
|
||||||
|
// This is what makes "stepCount = 0 on all of them" structural rather than lucky: stepCount
|
||||||
|
// is only meaningful for a discrete control, and every discrete control is reload or rebuild
|
||||||
|
// tier and therefore never reaches the list. UnitCategory::None is the deck's own name for
|
||||||
|
// "no continuous unit" — toggles, radios, the curve-popup cells, the integer voice count.
|
||||||
|
for (const ParamRow& row : exposedParams()) {
|
||||||
|
CHECK_ID(reasampler::instrument::ui::deckParamUnit(row.deck) !=
|
||||||
|
reasampler::instrument::ui::UnitCategory::None,
|
||||||
|
row.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testTheAddedDriveInverseUndoesTheFrozenLaw() {
|
||||||
|
using reasampler::instrument::engine::filter::filterDriveDepthFromNorm;
|
||||||
|
using reasampler::instrument::engine::filter::filterNormFromDriveDepth;
|
||||||
|
for (int step = 0; step <= 100; ++step) {
|
||||||
|
const float norm = static_cast<float>(step) / 100.0f;
|
||||||
|
const float back = filterNormFromDriveDepth(filterDriveDepthFromNorm(norm));
|
||||||
|
CHECK(std::fabs(back - norm) < 1e-6f);
|
||||||
|
}
|
||||||
|
CHECK(filterNormFromDriveDepth(0.0f) == 0.0f);
|
||||||
|
CHECK(filterNormFromDriveDepth(-1.0f) == 0.0f);
|
||||||
|
CHECK(filterNormFromDriveDepth(1000.0f) == 1.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
testEveryUnitStringAndRangeMatchesTheSpecifiedTable();
|
||||||
|
testEveryDefaultHasAnExactNormalizedPreimage();
|
||||||
|
testTheFiltersFourTakeTheirStoredNormVerbatim();
|
||||||
|
testToPlainIsMonotoneAcrossTheWholeTravel();
|
||||||
|
testTheEndpointsAreTheDeclaredPlainRange();
|
||||||
|
testNoExposedControlIsDiscrete();
|
||||||
|
testTheAddedDriveInverseUndoesTheFrozenLaw();
|
||||||
|
if (g_fail == 0) std::printf("param_units: all tests passed\n");
|
||||||
|
return g_fail == 0 ? 0 : 1;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user