Merge Γ-W4-T1: the instrument's 44 automatable parameters under a frozen id table, both VST3 delivery channels serviced, and a stated authority model bounding a lane's authority to the window the model has not caught up on
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.
|
||||
|
||||
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
|
||||
|
||||
@@ -84,17 +84,18 @@ There is no hot-reload. Copy the **Release** build's binary (`build/Release/` on
|
||||
|
||||
## 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 |
|
||||
|---|---|
|
||||
| `src/app/` | REAPER extension entry point |
|
||||
| `src/core/audio/` | pure audio-data math |
|
||||
| `src/core/capture/` | pure logic behind the capture pillar |
|
||||
| `src/core/instrument/` | pure VST3-instrument core (bake / engine / map / note / ui) |
|
||||
| `src/core/instrument/` | pure VST3-instrument core (bake / engine / map / note / param / ui) |
|
||||
| `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/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/model/` | the pure bank/sample index and its multi-bank container |
|
||||
| `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
|
||||
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
|
||||
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
|
||||
`main.cpp` only: API-pointer ownership, `ReaperPluginEntry`, and dispatch.
|
||||
|
||||
|
||||
+48
-17
@@ -1270,14 +1270,25 @@ value semantics, any deck geometry, or the bake's reset *membership* (W3-T2's).
|
||||
`stepCount` is asserted **zero on all 44**.
|
||||
- **A host automation lane moving a Live parameter moves a sounding note; a lane moving a
|
||||
NoteOnLatched parameter takes effect on the next note and does NOT trigger a reload or an
|
||||
engine rebuild** — assert the tier, not just the sound.
|
||||
engine rebuild** — assert the tier, not just the sound. All three NoteOnLatched controls now
|
||||
have a delivery test in `tests/test_live_delivery.cpp` (rate, key-track, Trigger length), each
|
||||
asserting BOTH halves: the sounding note byte-identical, the next note taking the value.
|
||||
- **A host automation point's AUTHORITY IS BOUNDED**, and its release is where both review passes
|
||||
went wrong: it outranks the model only until the UI thread has folded it in and republished.
|
||||
`shell/instrument/CLAUDE.md`'s Authority section states the model, `param_merge` enforces it,
|
||||
`test_param_merge` fails if a hold is never released or is released too early.
|
||||
- **No automation path reaches `reloadInstrument` or `rebuildVoiceEngine`.**
|
||||
- **A project saved before this change opens with every parameter reading the blob's value
|
||||
and sounds identical**; a project saved by this build opens in an older binary with its
|
||||
sound intact; and a project with automation drawn, saved and reopened, replays against the
|
||||
same plain values.
|
||||
- **`process()` takes no new indirection and no new per-sample work** — the parameter read is
|
||||
a block-boundary act, on the existing live-publish path.
|
||||
a block-boundary act. **Correction to the original wording ("on the existing live-publish
|
||||
path"):** it cannot be, and the SDK is what decides that. `IParameterChanges` is delivered ON
|
||||
the audio thread, and the model's publish path allocates (`resolvePlay` copies velocity curves
|
||||
and spline contours), so the drain lands in `process()` and patches the live block in place.
|
||||
The block-boundary rule is unchanged and the per-sample path is untouched; what moved is which
|
||||
thread performs the fold.
|
||||
- **The bake's reset notifies the host**, verified by the host's displayed value following it
|
||||
rather than snapping back on next touch.
|
||||
- **The double-processing limitation is documented, not discovered** — a bake whose
|
||||
@@ -1297,11 +1308,30 @@ value semantics, any deck geometry, or the bake's reset *membership* (W3-T2's).
|
||||
- **No [Daniel] questions. Γ-F7 is RULED — signal flow** (2026-08-01, *"signal flow order."*),
|
||||
and Ruling 3 (real units) arrived specified rather than forked. **There is no unanswered
|
||||
[Daniel]-class question in this track or anywhere in this plan.**
|
||||
- **[verify, FIRST]** that REAPER calls `setState` (not `setComponentState`) on a
|
||||
single-component plug-in, and the ordering of `setState` against the first
|
||||
`IParameterChanges` block after a project load. §6.1 is built on the SDK's own
|
||||
name-collapse; **verify it in the DAW before wiring, and do not build on the paragraph
|
||||
alone.**
|
||||
- **CLOSED from the SDK, not the DAW.** Two things were bundled here and they separate.
|
||||
(a) The DELIVERY CHANNEL: `ivsteditcontroller.h` documents `setParamNormalized` as the
|
||||
GUI-update channel ("should update the according GUI element(s) only"), and the SDK's own
|
||||
`SingleComponentEffect` sample (`public.sdk/samples/vst/again/source/againsimple.cpp`) drains
|
||||
`ProcessData::inputParameterChanges` in `process()` while also implementing
|
||||
`setParamNormalized`. **Both are serviced.** This was never a DAW question — the headers
|
||||
answer it, and building on the paragraph alone is exactly what the first pass did.
|
||||
(b) The ORDERING of `setState` against the first parameter block: no longer a question, but not
|
||||
for the reason the second pass gave. A point held by the audio thread is re-applied over every
|
||||
merge only until the UI thread has folded it into the model — the hold is bounded, and a lane
|
||||
that is genuinely DRIVING re-sends and so keeps outranking the restore, while a lane that sent
|
||||
one point and had it folded does not. That is the correct reading of the rule "a lane in
|
||||
read/write mode outranks a plug-in-side set" (stated in `docs/product/parameter-automation.md`
|
||||
§6.1 as reasoning from the host's replay behaviour, not from a header — the SDK does not spell
|
||||
it out). The second pass's unbounded latch made the claim true by making every later writer
|
||||
permanently deaf; `shell/instrument/CLAUDE.md`'s Authority section is the model now.
|
||||
(c) **STILL OPEN, and it is the half the bundled `[verify, FIRST]` originally asked**: that
|
||||
REAPER calls `setState` (not `setComponentState`) on a single-component plug-in — a state
|
||||
ENTRY-POINT question, not a delivery-channel one. Recorded in `docs/TODO.md` rather than closed:
|
||||
`vstsinglecomponenteffect.h:41-47` does collapse the names as §6.1 claims, our overrides land on
|
||||
the `IComponent` pair with `setEditorState`/`getEditorState` left at the base's `kNotImplemented`,
|
||||
and the blob has shipped through payload v1…v16, so the behaviour is very likely fine — but that
|
||||
is inference, not observation. The rest of the DAW work is in `docs/TODO.md` too, and none of it
|
||||
can change the frozen contract.
|
||||
- **[verify]** whether REAPER renders `ParameterInfo::units` beside the string
|
||||
`getParamStringByValue` returns, or shows the string alone. **We ship the SDK's own
|
||||
convention** — digits in the string, unit carried separately, which is what
|
||||
@@ -1309,16 +1339,17 @@ value semantics, any deck geometry, or the bake's reset *membership* (W3-T2's).
|
||||
REAPER shows no unit at all, the fallback is to append the unit **inside the one formatter**:
|
||||
a one-line change in one place, touching neither the frozen id table nor the editor, because
|
||||
display strings are explicitly not frozen (§6.7.1). Do not discover this after shipping.
|
||||
- **[propose at review]** promoting **key-track** and **Trigger length** from `Reload` to
|
||||
`NoteOnLatched` (§7.4). Both are excluded from the live set *for the note-on-latch reason*
|
||||
in the predicate's own words, so the promotion aligns routing with documented semantics —
|
||||
and it is what makes them automatable at all. **If either promotion is refused, that
|
||||
control simply drops out of the parameter list.** The list follows the predicate; the
|
||||
predicate is never bent to fill the list. **Consequence for the frozen table:** a refusal
|
||||
drops ids 1000 and 1260 (key-track) or 1450 (Trigger length) and the count falls below 44.
|
||||
Those slots are then simply **never issued** — not retired, since nothing shipped under
|
||||
them — and remain available to the same control if it is promoted later. No other id moves;
|
||||
that is what the block-and-step scheme buys.
|
||||
- **RULED (Daniel, 2026-08-02): promote both.** **Key-track** and **Trigger length** move from
|
||||
`Reload` to `NoteOnLatched` (§7.4) and are exposed; ids **1000** and **1450** issue and the
|
||||
count is **44 of 44**. **The promotion is NOT the predicate-only change this bullet originally
|
||||
advertised** — the predicate flip is the smallest part of it. Key-track lives on
|
||||
`InstrumentParams`, not `PlaySeconds`, so the host's write path (`setDeckParam`/`deckParamNorm`)
|
||||
structurally could not see it and id 1000 would have no-oped in both directions with no
|
||||
compile-time guard; both controls also had to reach the engine, which meant widening
|
||||
`LiveValues` and `foldLive`'s input and handing `Voice::start` the two latched values as
|
||||
arguments beside the rate. The guard that closes the class is `param::valueHomeFor` — asserted
|
||||
over the exposed set (every control has a home, the instance-scalar set has exactly two
|
||||
members) AND branched on by the shell's own read and write paths, so the three cannot drift.
|
||||
- **[propose at review]** whether to ship a default `IMidiMapping` CC table here or leave MIDI
|
||||
control to REAPER's host-side learn. Either is defensible; **skipping it silently is not.**
|
||||
- **[propose at review]** whether this track spends the reserved payload rung. §6.1 says
|
||||
|
||||
@@ -819,3 +819,21 @@ doc-keeper edit.
|
||||
|
||||
**Done looks like.** The enumeration distinguishes "in the project's state" from "on disk
|
||||
in the `.rpp`", and does not gain a second home for the distinction.
|
||||
|
||||
## The VST3 parameter surface's DAW-verifiable claims
|
||||
|
||||
**Context (what shipped — Γ-W4-T1).** The instrument reports 44 automatable parameters under the frozen id table, services both delivery channels (the controller's `setParamNormalized` and the audio thread's `IParameterChanges` drain), and folds automated values back into the blob on the UI thread.
|
||||
|
||||
**What is settled without a DAW.** The channel question itself is answered by the vendored SDK, not by observation: `ivsteditcontroller.h` documents `setParamNormalized` as the GUI-update channel ("should update the according GUI element(s) only"), and the SDK's own `SingleComponentEffect` sample (`public.sdk/samples/vst/again/source/againsimple.cpp`) drains `ProcessData::inputParameterChanges` in `process()` while also implementing `setParamNormalized`. Servicing both is what the SDK's own precedent does; it needs no verification, only exercise.
|
||||
|
||||
**What genuinely needs a running REAPER, and why none of it can change the design.** Each item below is a host BEHAVIOUR, not a contract — the plug-in is correct under either answer, so discovering the answer costs a display fix at worst:
|
||||
|
||||
1. **Whether REAPER renders `ParameterInfo::units` beside the string `getParamStringByValue` returns, or shows the string alone.** We ship the SDK's own convention (digits in the string, unit carried separately). If REAPER shows no unit at all, the fallback is to append the unit inside the one formatter — one line in one place, touching neither the frozen id table nor the editor, because display strings are explicitly not frozen.
|
||||
2. **Whether REAPER's own per-parameter MIDI learn covers what a shipped `IMidiMapping` CC table would have.** The decision to ship no default map rests on it; if learn does not reach these parameters, a CC table is additive and frozen by nothing.
|
||||
3. **That the three migration round trips hold**: a pre-parameter project opens with every parameter reading the blob's value and sounds identical; a project saved by this build restores fully in an older binary; a project with automation drawn, saved and reopened, replays against the same plain values.
|
||||
4. **That an offline render replays automation** — the sharpest case for the audio-side drain, because the host drives `process()` and may never touch the controller.
|
||||
5. **That REAPER restores instance state through `setState`, not `setComponentState`.** This is the ENTRY-POINT half of the original bundled `[verify, FIRST]`; the pass that closed that item closed only its delivery-channel half, which is a different question. The evidence short of a DAW is strong but is inference: `vstsinglecomponenteffect.h:41-47` collapses the two names on a single-component plug-in, our `setState`/`getState` overrides land on the `IComponent` pair with `setEditorState`/`getEditorState` left at the base's `kNotImplemented`, and the blob has round-tripped through payload v1…v16 in real projects. Exercising it costs one save/reopen.
|
||||
|
||||
**Priority / risk.** Low. Nothing here is load-bearing on the frozen contract: the id table, the plain ranges and the norm↔plain laws are all decided and tested without a host.
|
||||
|
||||
**Done looks like.** Each of the four exercised once in REAPER, with the unit-rendering answer recorded and, if it went the other way, the one-line formatter change made.
|
||||
|
||||
@@ -335,11 +335,31 @@ invariant Θ-W1-T1 was run to establish.
|
||||
> outranks anything the plugin sets, because the host replays it. That is inherent to
|
||||
> automation and is not a defect to design away — but it has one sharp consequence for the
|
||||
> resample bake, and that is §9.
|
||||
>
|
||||
> **Superseded by the paragraph immediately below.** "Outranks anything the plugin sets" reads
|
||||
> as unbounded; the bounded formulation there — outranks only until the model has caught up,
|
||||
> never a later restore/reset/knob move — is the correct one and the one `shell/instrument/
|
||||
> CLAUDE.md`'s Authority section and `core/instrument/param/param_merge` implement. An unbounded
|
||||
> hold was tried and is the specific defect this history keeps.
|
||||
|
||||
**[verify] at the track, before wiring:** that REAPER calls `setState` (not
|
||||
`setComponentState`) on a single-component plug-in, and the ordering of `setState` against
|
||||
the first `IParameterChanges` block after a project load. Verify against the vendored SDK
|
||||
and in the DAW — do not build on the paragraph above without it.
|
||||
**SETTLED at the track, from the vendored SDK.** The delivery question the `[verify]` here
|
||||
bundled is answered by the headers rather than by the DAW: `setParamNormalized` is documented as
|
||||
the GUI-update channel (*"should update the according GUI element(s) only"*,
|
||||
`ivsteditcontroller.h`), and `ProcessData::inputParameterChanges` is the audio-side one — the
|
||||
SDK's own `SingleComponentEffect` sample services BOTH
|
||||
(`public.sdk/samples/vst/again/source/againsimple.cpp`), and so do we. The `setState` ordering
|
||||
half dissolves with it, but only because the hold is BOUNDED: an automation point held by the
|
||||
audio thread is re-applied over every merge until the UI thread folds it into the model, so a
|
||||
lane that is genuinely driving outranks the restore whichever way round the two arrive, while a
|
||||
lane that sent one point and had it folded does not. That is the authority rule read correctly —
|
||||
and note it is reasoning from the host's replay behaviour, not a header quote: the SDK does not
|
||||
state it. An unbounded hold makes the ordering claim true by making every later writer
|
||||
permanently deaf, which is not the same property. `shell/instrument/CLAUDE.md`'s Authority
|
||||
section is the model, and `core/instrument/param/param_merge` is where it is enforced.
|
||||
**The audio thread cannot run the model path**
|
||||
(`resolvePlay` copies velocity curves and spline contours, so it allocates), so the drain patches
|
||||
the live block in place through one pure RT-safe function whose routing is pinned by an
|
||||
exhaustive equivalence test against the model path.
|
||||
|
||||
### 6.2 The ID space: hand-assigned constants in one frozen table
|
||||
|
||||
@@ -897,11 +917,20 @@ state fits them exactly:
|
||||
- **Trigger length** — *"resolves `playEnd_`, a fact about the note, not a setting of it."*
|
||||
Same shape.
|
||||
|
||||
**[propose at review, Γ-W4-T1]** promote both. The promotion aligns the routing with the
|
||||
predicate's own stated semantics — and it is what makes them automatable, since today they
|
||||
would re-decode a WAV per automation point. **If either promotion is refused, that control
|
||||
simply drops out of the parameter list.** The list follows the predicate; the predicate is
|
||||
never bent to fill the list.
|
||||
**RULED (Daniel, 2026-08-02): promote both.** Ids 1000 and 1450 issue; the count is 44 of 44.
|
||||
The promotion aligns the routing with the predicate's own stated semantics — and it is what
|
||||
makes them automatable, since otherwise they would re-decode a WAV per automation point.
|
||||
|
||||
**It was not the predicate-only change this section implied.** Key-track lives on
|
||||
`InstrumentParams`, not `PlaySeconds`, so the host's write path could not reach it and id 1000
|
||||
would have no-oped in both directions with nothing failing to compile; both controls also had to
|
||||
reach the engine, which widened `LiveValues` and `foldLive`'s input and gave `Voice::start` the
|
||||
two latched values as arguments beside the rate. `param::valueHomeFor` is what makes the next
|
||||
promotion of this shape a test failure instead of a silence, and it earns that claim in three
|
||||
places rather than one: `test_param_live` asserts every exposed control HAS a home and that the
|
||||
instance-scalar set has exactly two members, and the shell's own read and write paths
|
||||
(`modelParamNormalized`, `writeDeckParamToModel`) now BRANCH on it rather than on a hardcoded
|
||||
control id — so a third instance scalar cannot appear without failing that count.
|
||||
|
||||
**Not promoted, and not proposed for promotion: Rate to Live.** §3.5 records the cost;
|
||||
that paragraph is the first thing to read if it is ever proposed.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# src/core/instrument — pure VST3-instrument core (bake / engine / map / note / ui)
|
||||
# src/core/instrument — pure VST3-instrument core (bake / engine / map / note / param / ui)
|
||||
|
||||
## Scope
|
||||
|
||||
The ReaSampler 9000 instrument's pure, REAPER-free, VST3-free, unit-tested core, in five
|
||||
The ReaSampler 9000 instrument's pure, REAPER-free, VST3-free, unit-tested core, in six
|
||||
subdirectories:
|
||||
|
||||
- **`engine/`** — the polyphonic voice engine, the one set of play params, pitch shifting,
|
||||
@@ -17,6 +17,11 @@ subdirectories:
|
||||
- **`bake/`** — the resample bake's pure half: the programmed note resolved to a frame
|
||||
window, the offline render over a voice engine built for that render alone, and the
|
||||
ratified post-bake reset. See `bake/CLAUDE.md`.
|
||||
- **`param/`** — what the instrument tells a VST3 host about its automatable parameters,
|
||||
with no VST3 type in it: the FOREVER-FROZEN id table, the exposed set derived from
|
||||
`deckParamCommit`, the plain-value layer, and the one formatter per unit category. Sits
|
||||
ABOVE `ui/` — the list is a function of the commit predicate, never the reverse. See
|
||||
`param/CLAUDE.md`.
|
||||
- **`ui/`** — pure editor geometry/hit-test modules (the band-stack allocator and its band
|
||||
interiors, waveform, keyboard strip, capture browser, param controls, envelope
|
||||
overlay/edit). These are geometry-and-math only; the LICE draw + REAPER/VST3 plumbing is
|
||||
@@ -284,7 +289,7 @@ anything for a trigger shape.
|
||||
- The engine is the `sampler_core` CMake target over FOUR headers and TWO TUs, split on its own responsibility seam — cold note routing vs the hot per-sample render:
|
||||
- `play_params.h` — the value layer: `PlayParams`/`AdsrParams`/`TriggerParams`/`PitchEnvParams`/`FilterParams`, the per-instance mode enums (`ChannelMode`/`VoiceMode`/`MonoTrigger`), and `SampleData` (the ONE loaded capture: decoded PCM + root + loop + start + keyTrack + velocity curve + play params). Shared by the engine, the codec, and the editor, so a UI/codec TU reading a param struct doesn't recompile when a `Voice` member changes. `FilterParams` stores the filter module's own `FilterSettings` by value rather than a parallel copy of its normalized positions. Also the ONE home of the drawn-EG rule family — `splineActive`, `effectivePlayMode`, `enforceGateUnavailableWhileDrawn` and `effectiveLengthFraction` — all templated over the frames and seconds representations, so no consumer of either can re-read the raw fields instead.
|
||||
- `envelopes.h` — the three per-frame evaluators (`AdsrEnvelope` AHDSR, `AhdEnvelope` the sustain-less Attack/Hold/Decay, `PitchEnvelope` the AHD pitch offset), CONCRETE and fully header-inline. Never give them a common base or a virtual `tick()`: they are called per-voice-per-sample. Also home to `fitAhd`/`ahdLevelAt`, THE span split and shape every sustain-less envelope shares. A voice carries two of each shape — the amp's and the filter's — and its play mode picks which pair it reads. `AdsrEnvelope`/`PitchEnvelope` own `applyLive` (the φ-holding mid-stage rule), its fresh-note peer `snapLive`, and `StepSmoother`, the bounded offset that absorbs the level steps φ cannot cover; `AhdEnvelope` is POSITIONAL (evaluated at a source offset, not ticked), so it has no phase to hold and smooths a live reshape instead.
|
||||
- `live_params.h` / `live_params.cpp` — the live-parameter block: `LiveValues` (the plain, trivially-copyable bundle the audio thread observes), the single-writer `LiveParams` seqlock that publishes it without a lock or a torn read, `foldLive` (the ONE derivation from `PlayParams` — every publisher goes through it so the two representations cannot drift), and `ValueRamp`, the per-frame glide whose EXACT termination is what lets the filter's equality-compare cutoff skip re-engage. Links no engine: the block is a value the voice observes, not a thing the engine owns.
|
||||
- `live_params.h` / `live_params.cpp` — the live-parameter block: `LiveValues` (the plain, trivially-copyable bundle the audio thread observes), the single-writer `LiveParams` seqlock that publishes it without a lock or a torn read, `foldLive` (the ONE derivation from `PlayParams` — every publisher goes through it so the two representations cannot drift), the block's FIELD-wise `operator==` (never a memcmp — the header owns why the padding makes a byte compare report differences that do not exist), and `ValueRamp`, the per-frame glide whose EXACT termination is what lets the filter's equality-compare cutoff skip re-engage. Links no engine: the block is a value the voice observes, not a thing the engine owns.
|
||||
- `voice.h` / `voice.cpp` — one voice. The per-SAMPLE render half (`advanceFrame` and everything it calls) is INLINE IN THE HEADER by RT constraint; the per-NOTE half (note-on setup incl. the Preserve ring prime, legato retune, gate-off, the off-thread shifter presize) is out of line in the TU. The voice owns its own `VoiceFilter` and filter envelope, run between the pitch stage and the amp multiply — see `engine/filter/CLAUDE.md`. **Documented ~600-line-ceiling exception** (root `CLAUDE.md` structural heuristic 1): `voice.h` sits over the ceiling because `advanceFrame`'s RT-inline constraint forbids the seam a split would need — a documented exception, not silent overshoot.
|
||||
- `voice_engine.h` / `voice_engine.cpp` — `VoiceEngine`: note routing, bounded-stealing allocation, user-parameterized voice count (1–32, default 16), `VoiceMode` Poly/Mono (last-note held-note stack, `MonoTrigger` Retrigger/Legato), two-tier panic (CC 123 = all-notes-off release, CC 120 = immediate hard-stop including Trigger one-shots), and the block render loops. Preview injects a synthetic note-on at the loaded capture's root note into the main `VoiceEngine` — no dedicated `PreviewCard`; preview obeys polyphony/mono/voice-stealing/envelopes.
|
||||
- `engine/loop/` — the sustain loop's ONE validity/clamp fold (`resolveLoop`) plus its pre-seam crossfade geometry and the editor's default handle span; see `engine/loop/CLAUDE.md`. The voice folds it once at note-on; the crossfade weight is header-inline because it rides the per-sample read.
|
||||
@@ -309,7 +314,7 @@ anything for a trigger shape.
|
||||
never buy itself a higher lowest-findable fundamental.
|
||||
- `time_stretch` — the TIME half beside `pitch_shift`'s PITCH half, header-only: `StretchCursor`, the per-output-frame source-feed schedule (a fractional cursor carrying its rate debt, loop-wrapped), plus the rate bounds and their clamp. Rate 1.0 is exactly one source frame per output frame with no residue, which is what makes the unity Preserve read bit-identical to the pre-stretch engine. The bounds are **measured**, not arbitrary — see the header.
|
||||
- `velocity_curve` — THE monotone spline, shared by every consumer: the three velocity transfer curves and the three spline EGs. `VelocityCurve` is evaluated as ONE OR MORE Fritsch–Carlson monotone cubic Hermite splines joined at its HARD points — a hard knot is a sub-curve boundary for tangent purposes (exactly what the point array's own ends already are), so the two adjacent segments meet at their natural angle instead of a shared derivative and the no-overshoot guarantee holds PER SEGMENT rather than globally. Points are smooth by default; the ceiling is `kMaxCurvePoints` = 128, a MUSICAL bound (long rhythmic phrases, ~two points per articulation event) and not a performance one — **do not lower it**. `eval(velocity)` is the COLD reader, called once per note-on or once per drawn pixel column; `SplineCursor` is the RT one, an indexed segment search plus one Hermite evaluation with the segment and its tangents cached across samples. Both share the same `segmentTangents`/`hermiteAt` free functions, so there is one spline and not two. It carries its own y `CurveDomain`: UNIPOLAR [0,1] is the amp's GAIN, defaulting to `flat()` (y=1, every velocity→unity — a deliberate non-back-compat replacement of the old fixed `velocity/127` path, Daniel-approved); BIPOLAR [−1,1] is the signed modulation shape for pitch and filter, defaulting to `zero()` so velocity modulates neither until a curve is drawn. A bipolar curve does not imply the absence of a depth beside it: the filter keeps its `velAmount` knob and the two compose multiplicatively (`velAmount × curve.eval(v)`, `play_params.h`), while the pitch curve's throw is the fixed `kVelocityPitchRangeSemitones`.
|
||||
- `master_gain` — pure dB↔linear taper math (FB1): normalized [0,1] ↔ dB ↔ linear for the post-mixer master gain control (−∞…+24 dB, norm 0 = true silence, unity ≈ 0.714). Shared by the editor knob and the processor multiply so the needle, persisted value, and audio multiply cannot drift.
|
||||
- `master_gain` — pure dB↔linear taper math (FB1): normalized [0,1] ↔ dB ↔ linear for the post-mixer master gain control (−∞…+24 dB, norm 0 = true silence, unity ≈ 0.714). Shared by the editor knob, the processor multiply and the host's `toPlain` so the needle, persisted value, audio multiply and reported dB cannot drift. Math only — the dB label is `param/param_format`'s, so the editor and the host cannot print it two ways.
|
||||
- `limiter` — the master bus's lookahead brickwall limiter, the stage after `master_gain`'s multiply: a 4x-oversampled TRUE-PEAK detector in the SIDECHAIN ONLY (the signal path is never oversampled), one stereo-linked gain, a baked −0.3 dBTP ceiling and **no makeup gain of any kind**. The gain law is a sliding MINIMUM of the per-sample target over the lookahead window followed by a MOVING AVERAGE of the same width: every term of that average is a minimum whose own window contains the sample being gained, so the ceiling is held **structurally** rather than by a tuned attack, and the one-pole release only ever slows the RISE so that bound survives it. Bypassed and settled, `process()` returns without reading or writing a sample — the byte-identical at-rest path, on the same discipline as `live == nullptr` and the filter's exact skip at `modAmount == 0`. `prepare()` owns every allocation and every transcendental. **Switching is a MUTE, never a blend:** unlimited signal is emitted at weight 1 (the untouched bypass buffer) or at weight 0 and never in between, because a fraction of an unlimited signal is a peak over the ceiling — so the fade always rides the limited path and the hard edge always lands on the bypassed side, against silence. Do not reintroduce an equal-gain dry/wet crossfade over the toggle.
|
||||
- `meter_ballistics` — the output meter's UI-side ballistics and dB scale: instantaneous rise, 20 dB/s fall, the 1.5 s peak hold and its release at the same rate, the clip latch, and the dB → normalized map over −60…+6 dBFS. The audio thread publishes raw block peaks and converts nothing; this module is what turns them into what the bar draws. Per-channel and stage-agnostic — the MASTER column's own state (both channels plus the gain-reduction lamp) composes it in `ui/master_meter`.
|
||||
|
||||
@@ -349,10 +354,10 @@ anything for a trigger shape.
|
||||
value is COPIED rather than round-tripped: that taper bypass is mandatory and must never be
|
||||
"simplified" back into a norm round trip), `deckParamUnit`/`snapDeckParamNorm` (THE snap-unit
|
||||
table, and where each control's full scale enters — a whole DISPLAYED percent is a different
|
||||
norm step at 0..100 %, 0..200 % and ±100 %), and `formatEnvTimeMs`, the
|
||||
ONE time-constant formatter: every displayed time constant reads in **ms**, never seconds, so
|
||||
two stage times are comparable at a glance. A display-unit decision only — nothing about the
|
||||
stored representation changes. Links the header-only `play_seconds`, deliberately not
|
||||
norm step at 0..100 %, 0..200 % and ±100 %). Display FORMATTING is not here — `param/`'s
|
||||
`param_format` owns the one formatter per unit category, because the host and the editor must
|
||||
be its two callers and neither may hold a second implementation. Links the header-only
|
||||
`play_seconds`, deliberately not
|
||||
`sample_map`: `PlaySeconds` is the whole of what a deck edits, and linking the mapping would
|
||||
drag the bank model and the WAV codec in behind it. The shell keeps only the controls the
|
||||
parameter set does not carry (key-track, voice count, master gain, preview velocity) and the
|
||||
|
||||
@@ -2,6 +2,8 @@ add_subdirectory(engine)
|
||||
add_subdirectory(map)
|
||||
add_subdirectory(note)
|
||||
add_subdirectory(ui)
|
||||
# After ui: the VST3 parameter identity reads the deck's commit predicate and its value binding.
|
||||
add_subdirectory(param)
|
||||
# Last: bake composes the three above it.
|
||||
add_subdirectory(bake)
|
||||
|
||||
|
||||
@@ -54,6 +54,16 @@ decision about what the render made obsolete.
|
||||
- **`kStageTimeMaxSeconds` (the stage-time ceiling `param_taper` owns) is not a reset-list
|
||||
candidate at all** — it bounds a knob's taper, is never itself a dialed value, and so has
|
||||
no disposition to classify against the ratified reset rule.
|
||||
- **A host automation lane outranks the reset, and the bake cannot clear it — a NAMED
|
||||
limitation, not a bug.** Every reset-class value that is also an exposed VST3 parameter is
|
||||
now notified to the host (the reset writes through `setInstrumentParams`, which is the one
|
||||
notification funnel), so the host's DISPLAY follows the reset. A lane, however, lives in the
|
||||
host's project data: if a reset-class parameter carries one, the host replays its curve onto
|
||||
audio that already has that processing baked in — double processing, and the "sounds as the
|
||||
dialled instrument sounded just before the click" claim does not hold in that case. There is
|
||||
no detection available: `IAutomationState` reports the host's automation mode for the whole
|
||||
plug-in, not per parameter, so both "refuse the bake" and "reset only the un-automated ones"
|
||||
are unbuildable rather than merely unattractive. The user's remedy is to remove the lane.
|
||||
|
||||
## Modules
|
||||
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
# Control mapping, SVF coefficients, morph weights, and the filter type each get their own
|
||||
# TU; VoiceFilter::process stays header-inline so the kernel still inlines at the call site.
|
||||
# The frozen control-position laws are their own target: they are the filter's PARAMETER
|
||||
# surface, and the VST3 parameter layer reports Hz/Q/drive through them. Kept separable so
|
||||
# that consumer does not take a link edge onto the per-voice kernel — the extension's link
|
||||
# graph must never be able to reach the voice DSP (root CLAUDE.md, the bake invariant).
|
||||
reasampler_pure_library(filter_params SOURCES filter_params.cpp)
|
||||
|
||||
reasampler_pure_library(filter SOURCES
|
||||
filter_params.cpp
|
||||
filter_coeffs.cpp
|
||||
filter_morph.cpp
|
||||
voice_filter.cpp)
|
||||
voice_filter.cpp
|
||||
LINK PUBLIC filter_params)
|
||||
|
||||
# Four test targets along the module's own seams so each asserts one domain. filter_tests
|
||||
# alone owns the analytic reference and the steady-state gain measurement — a forked copy of
|
||||
|
||||
@@ -52,6 +52,13 @@ float filterDriveDepthFromNorm(float norm) {
|
||||
return static_cast<float>(kFilterDriveDepthMax * n * n);
|
||||
}
|
||||
|
||||
float filterNormFromDriveDepth(float depth) {
|
||||
if (!(depth > 0.0f)) return 0.0f; // also catches NaN
|
||||
if (depth >= kFilterDriveDepthMax) return 1.0f;
|
||||
return static_cast<float>(
|
||||
std::sqrt(static_cast<double>(depth) / static_cast<double>(kFilterDriveDepthMax)));
|
||||
}
|
||||
|
||||
float filterNormFromQ(float q) {
|
||||
if (!(q > kFilterQMin)) return 0.0f;
|
||||
if (q >= kFilterQMax) return 1.0f;
|
||||
|
||||
@@ -49,4 +49,9 @@ float filterNormFromQ(float q);
|
||||
// linear rather than merely close.
|
||||
float filterDriveDepthFromNorm(float norm);
|
||||
|
||||
// Exact inverse of filterDriveDepthFromNorm; out-of-range depth clamps to 0 or 1. The analytic
|
||||
// inverse of a frozen law is not a change to it — it has the standing the two inverses above
|
||||
// already have.
|
||||
float filterNormFromDriveDepth(float depth);
|
||||
|
||||
} // namespace reasampler::instrument::engine::filter
|
||||
|
||||
@@ -5,8 +5,17 @@
|
||||
|
||||
namespace reasampler::instrument::engine {
|
||||
|
||||
LiveValues foldLive(const PlayParams& params) {
|
||||
LiveValues v;
|
||||
LiveValues foldLive(const PlayParams& params, double keyTrack) {
|
||||
// Value-initialized, so the padding is determinate too. Nothing reads it — the block's
|
||||
// equality is field-wise for exactly that reason — but this is the one construction site
|
||||
// every publisher goes through, and an object with indeterminate bytes travelling under a
|
||||
// seqlock is a hazard worth not having. Off the audio thread; the memset costs nothing here.
|
||||
LiveValues v{};
|
||||
v.keyTrack = keyTrack;
|
||||
// Folded here, not at the voice: Voice::start reads the block's value directly, so the
|
||||
// spline rule has to be applied on the way in or the two would answer differently.
|
||||
v.splineActive = splineActive(params);
|
||||
v.lengthFraction = effectiveLengthFraction(params);
|
||||
v.filterSettings = params.filter.settings;
|
||||
v.filterModAmount = params.filter.modAmount;
|
||||
v.filterVelAmount = params.filter.velAmount;
|
||||
@@ -21,6 +30,48 @@ LiveValues foldLive(const PlayParams& params) {
|
||||
return v;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
bool sameAdsr(const AdsrParams& a, const AdsrParams& b) {
|
||||
return a.attackFrames == b.attackFrames && a.holdFrames == b.holdFrames &&
|
||||
a.decayFrames == b.decayFrames && a.sustainLevel == b.sustainLevel &&
|
||||
a.releaseFrames == b.releaseFrames && a.attackCurve == b.attackCurve &&
|
||||
a.decayCurve == b.decayCurve && a.releaseCurve == b.releaseCurve;
|
||||
}
|
||||
|
||||
bool sameAhd(const AhdParams& a, const AhdParams& b) {
|
||||
return a.attackFrames == b.attackFrames && a.decayFrames == b.decayFrames &&
|
||||
a.holdFraction == b.holdFraction && a.attackCurve == b.attackCurve &&
|
||||
a.decayCurve == b.decayCurve;
|
||||
}
|
||||
|
||||
bool sameFilterSettings(const filter::FilterSettings& a, const filter::FilterSettings& b) {
|
||||
return a.cutoffNorm == b.cutoffNorm && a.resonanceNorm == b.resonanceNorm &&
|
||||
a.morphNorm == b.morphNorm && a.driveNorm == b.driveNorm &&
|
||||
a.morphLaw == b.morphLaw;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool operator==(const LiveValues& a, const LiveValues& b) {
|
||||
return sameFilterSettings(a.filterSettings, b.filterSettings) &&
|
||||
a.filterModAmount == b.filterModAmount &&
|
||||
a.filterVelAmount == b.filterVelAmount &&
|
||||
a.filterKeyTrack == b.filterKeyTrack &&
|
||||
sameAdsr(a.filterEnv, b.filterEnv) &&
|
||||
sameAhd(a.filterAhd, b.filterAhd) &&
|
||||
sameAdsr(a.adsr, b.adsr) &&
|
||||
sameAhd(a.ampAhd, b.ampAhd) &&
|
||||
a.pitchEnv.enabled == b.pitchEnv.enabled &&
|
||||
a.pitchEnv.peakSemitones == b.pitchEnv.peakSemitones &&
|
||||
sameAhd(a.pitchEnv.shape, b.pitchEnv.shape) &&
|
||||
a.playRate == b.playRate &&
|
||||
a.pitchOffsetSemitones == b.pitchOffsetSemitones &&
|
||||
a.keyTrack == b.keyTrack &&
|
||||
a.lengthFraction == b.lengthFraction &&
|
||||
a.splineActive == b.splineActive;
|
||||
}
|
||||
|
||||
double liveRampStep(double sampleRate) {
|
||||
if (!(sampleRate > 0.0)) return 0.0; // also catches NaN
|
||||
return 1.0 / (kLiveRampSeconds * sampleRate);
|
||||
|
||||
@@ -52,15 +52,49 @@ struct LiveValues {
|
||||
// ordinarily live.
|
||||
double playRate = 1.0;
|
||||
double pitchOffsetSemitones = 0.0;
|
||||
// Two more members of playRate's note-on-latched class, here for the same reason it is:
|
||||
// both resolve a fact the voice fixes at note-on (the pitch ratio, and playEnd_), so live
|
||||
// delivery would retune or re-span a note already struck. Voice::start receives them as
|
||||
// arguments; applyLive never touches either.
|
||||
double keyTrack = kKeyTrackDefault;
|
||||
// ALREADY spline-folded (effectiveLengthFraction) — a drawn contour is a pure time function
|
||||
// over the whole sample, so the stored knob is inert while one is active and the block must
|
||||
// carry what the voice will actually play, not the stored value.
|
||||
double lengthFraction = 1.0;
|
||||
// The drawn-EG state the fold above reads. A mode flip travels by reload like the contours
|
||||
// themselves, so this is not a control; it rides here only so a block-boundary write of
|
||||
// Trigger length (a host automation point) can apply the SAME fold rather than un-doing it.
|
||||
bool splineActive = false;
|
||||
};
|
||||
|
||||
// The seqlock copies the block as raw bytes, which is only defensible for a plain value type.
|
||||
static_assert(std::is_trivially_copyable_v<LiveValues>,
|
||||
"the live block is copied under a seqlock — it must stay a plain value");
|
||||
// A SIZE-CHANGING edit only: padding can absorb a member added beside an existing one (a bool
|
||||
// beside splineActive, a fifth FilterSettings float) without moving this literal at all, so this
|
||||
// assert is NOT the guard against a forgotten operator== field —
|
||||
// testEveryFieldOfLiveValuesIsCompared (test_live_params.cpp) is that guard, poisoning one leaf
|
||||
// at a time. This assert only catches an edit that changes sizeof(LiveValues) itself. Confirmed
|
||||
// 352 bytes, MSVC 19.44 x64, Release (`SizeProbe<sizeof(LiveValues)>`, an incomplete-template
|
||||
// size probe whose error message reports the value). Bump the literal AND operator== together.
|
||||
static_assert(sizeof(LiveValues) == 352,
|
||||
"a member was added or removed — extend operator== in live_params.cpp to match");
|
||||
|
||||
// FIELD-wise equality, and it must never be "simplified" into a memcmp. LiveValues carries
|
||||
// padding, and nothing gives that padding a determinate value across a copy: NRVO is optional
|
||||
// and the implicit copy/move is specified member-wise, so two blocks folded from the same
|
||||
// parameter set are NOT reliably byte-equal. A byte compare therefore reports differences that
|
||||
// do not exist — which is exactly what it did before this existed. Listed member by member, so a
|
||||
// member added to the block above must be added here as well; this sits directly beneath the
|
||||
// struct for that reason.
|
||||
bool operator==(const LiveValues& a, const LiveValues& b);
|
||||
inline bool operator!=(const LiveValues& a, const LiveValues& b) { return !(a == b); }
|
||||
|
||||
// The ONE derivation of the live block from the parameter set. Every publisher goes through
|
||||
// here so there is a single site to keep in step with PlayParams.
|
||||
LiveValues foldLive(const PlayParams& params);
|
||||
// here so there is a single site to keep in step with PlayParams. `keyTrack` is passed in
|
||||
// because it belongs to the capture/instrument scalar beside the play bundle, not to
|
||||
// PlayParams — SampleData::keyTrack at the reload, InstrumentParams::keyTrack at a live commit.
|
||||
LiveValues foldLive(const PlayParams& params, double keyTrack);
|
||||
|
||||
// Single-writer / single-reader seqlock. The writer publishes a whole block between an odd
|
||||
// and an even generation; the reader copies the block and re-checks the generation, retrying
|
||||
@@ -96,6 +130,10 @@ public:
|
||||
seq_.store(next, std::memory_order_release); // even: complete and coherent
|
||||
}
|
||||
|
||||
// The last generation published, without copying the block — one relaxed load, so a reader
|
||||
// that only needs "has anything moved" pays nothing for asking on a block where nothing has.
|
||||
std::uint32_t generation() const { return seq_.load(std::memory_order_relaxed); }
|
||||
|
||||
// Copies the block into `out` and returns the generation actually observed, or 0 when
|
||||
// nothing has been published yet or the retry budget ran out (in which case `out` may hold
|
||||
// a torn copy and MUST be discarded — compare the return against 0 before using it).
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <limits>
|
||||
|
||||
namespace reasampler::instrument::engine {
|
||||
@@ -18,6 +17,13 @@ double masterGainMaxLinear() { return std::pow(10.0, kMasterGainMaxDb / 20.0); }
|
||||
double masterGainDbFromNorm(double norm) {
|
||||
norm = clamp01(norm);
|
||||
if (norm <= 0.0) return -std::numeric_limits<double>::infinity();
|
||||
// UNITY IS EXACT, and the argument is arithmetic rather than structural — a host's
|
||||
// reset-to-default arrives here as toPlain(defaultNormalized) and must land on 0.0 dB, not a
|
||||
// hair off it. fl(60/84) differs from 60/84 by δ ≈ 1.6e-17; 84·δ ≈ 1.33e-15 sits under the
|
||||
// half-ulp of 60 (3.55e-15), so -60 + fl(60/84)·84 rounds to exactly 60 and the sum to 0.
|
||||
// PRECONDITION: no FP contraction. Fused into a single FMA the residue survives as 1.33e-15.
|
||||
// Safe on the shipped MSVC/x64 default (no FMA without /arch:AVX2); a build that enables
|
||||
// contraction here breaks the exactness test in test_param_units, which is where it surfaces.
|
||||
return kMasterGainMinDb + norm * (kMasterGainMaxDb - kMasterGainMinDb);
|
||||
}
|
||||
|
||||
@@ -37,15 +43,4 @@ double masterGainNormFromLinear(double linear) {
|
||||
return masterGainNormFromDb(20.0 * std::log10(linear));
|
||||
}
|
||||
|
||||
void formatMasterGainLabel(double norm, char* buf, std::size_t len) {
|
||||
if (!buf || len == 0) return;
|
||||
norm = clamp01(norm);
|
||||
if (norm <= 0.0) {
|
||||
std::snprintf(buf, len, "-inf");
|
||||
return;
|
||||
}
|
||||
const double db = masterGainDbFromNorm(norm);
|
||||
std::snprintf(buf, len, "%+.1fdB", db);
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::engine
|
||||
|
||||
@@ -7,8 +7,6 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
namespace reasampler::instrument::engine {
|
||||
|
||||
// norm 0 is -inf (true zero); norm just above 0 starts at the finite floor kMasterGainMinDb
|
||||
@@ -31,8 +29,4 @@ double masterGainLinearFromNorm(double norm);
|
||||
// true-zero and the floor aren't representable on the knob. Out-of-range/non-finite clamps.
|
||||
double masterGainNormFromLinear(double linear);
|
||||
|
||||
// "-inf" at the bottom, else a signed one-decimal dB string ("-12.0dB", "+2.4dB").
|
||||
// Writes at most `len` bytes including the terminator.
|
||||
void formatMasterGainLabel(double norm, char* buf, std::size_t len);
|
||||
|
||||
} // namespace reasampler::instrument::engine
|
||||
|
||||
@@ -148,6 +148,11 @@ struct FilterParams {
|
||||
// with the pitch envelope's own depth throw so the two pitch modulators speak one range.
|
||||
inline constexpr double kVelocityPitchRangeSemitones = 24.0;
|
||||
|
||||
// Standard 12-tone-ET tracking, and the ONE home for that number: the capture's own scalar, the
|
||||
// instrument's stored scalar and the live block all default from here, so a blob predating the
|
||||
// field and a block published before the first note can never disagree about it.
|
||||
inline constexpr double kKeyTrackDefault = 1.0;
|
||||
|
||||
// Bundle a voice reads at start(). Defaults reproduce the bare engine (Gate, hold-0 AHDSR,
|
||||
// Varispeed, pitch envelope off, filter off, no velocity->pitch) — core regression tests rely
|
||||
// on this; the Preserve product default is layered on at (de)serialization, see
|
||||
@@ -267,7 +272,7 @@ struct SampleData {
|
||||
// How far keyboard pitch tracks the root: 1.0 = standard 12-tone-ET (default); 0.0 = no
|
||||
// tracking (every key plays root pitch); 2.0 = double-rate. Scales the (note-root) semitone
|
||||
// offset in keyTrackedRatio; rides both repitch engines via the voice's baseRatio_.
|
||||
double keyTrack = 1.0;
|
||||
double keyTrack = kKeyTrackDefault;
|
||||
|
||||
// Maps note-on velocity (0..127) to the voice's amp gain, eval'd once in Voice::start
|
||||
// (never per frame). Default flat y=1 — every velocity plays at unity.
|
||||
|
||||
@@ -19,7 +19,7 @@ void Voice::presizePreserveShifters(std::int64_t windowFrames) {
|
||||
}
|
||||
|
||||
void Voice::start(int note, int velocity, const SampleData& sample, bool declickTakeover,
|
||||
double stretchRate) {
|
||||
double stretchRate, double keyTrack, double lengthFraction) {
|
||||
// Before any state reset, record the pre-cut reference (last rendered output) and mark
|
||||
// the compensation pending iff this start is a takeover/steal of a sounding voice and the
|
||||
// caller opted in. The ramp is seeded on the first frame rendered after the restart, from
|
||||
@@ -72,6 +72,7 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
|
||||
// the control outright — the predicate is spelled the same way advanceFrame spells it.
|
||||
preserveRead_ = (pitchEngine_ == PitchEngine::Preserve) && shiftL_.configured();
|
||||
rateRatio_ = preserveRead_ ? 1.0 : stretchRate_;
|
||||
keyTrack_ = (keyTrack < 0.0) ? sample.keyTrack : keyTrack;
|
||||
recomputeBaseRatio();
|
||||
// pitchOffsetRatio_ is a power of 2 and never zero, so this inverse is well-defined — and at
|
||||
// Pitch 0 it is a division by exactly 1.0.
|
||||
@@ -120,10 +121,9 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
|
||||
} else {
|
||||
// Trigger: play [start, playEnd) where playEnd = start + round(frac*(frames-start)) —
|
||||
// map/trigger_seam.h's formula, evaluated inline because the engine does not depend on
|
||||
// map/. The spline fold is effectiveLengthFraction (play_params.h); a second copy of it
|
||||
// here is what let a stored-but-inert %-knob shorten the bake while the voice played
|
||||
// the whole take.
|
||||
double frac = effectiveLengthFraction(p);
|
||||
// map/. The caller's value is ALREADY spline-folded (foldLive does it); the snapshot
|
||||
// fallback folds here, because a stored-but-inert %-knob must not shorten the span.
|
||||
double frac = (lengthFraction < 0.0) ? effectiveLengthFraction(p) : lengthFraction;
|
||||
if (!(frac > 0.0)) frac = 0.0; // %=0 (or a corrupt NaN) -> finishes immediately
|
||||
if (frac > 1.0) frac = 1.0;
|
||||
std::int64_t playLen = static_cast<std::int64_t>(
|
||||
|
||||
@@ -45,6 +45,9 @@ inline double pitchRatio(int note, int rootNote) {
|
||||
// ((note-root)*1.0 is exact in IEEE-754 for an integer-valued double, feeding the same
|
||||
// std::pow call); 0.0 means every key plays the root pitch; 2.0 doubles the tracking rate.
|
||||
// At the root note the offset is 0 regardless of keyTrack.
|
||||
// "Not supplied" for Voice::start's two snapshot-defaulted note-on latches; see start().
|
||||
inline constexpr double kLatchFromSnapshot = -1.0;
|
||||
|
||||
inline double keyTrackedRatio(int note, int rootNote, double keyTrack) {
|
||||
const double semis = static_cast<double>(note - rootNote) * keyTrack;
|
||||
return std::pow(2.0, semis / 12.0);
|
||||
@@ -124,8 +127,16 @@ public:
|
||||
// to consult gets exactly that; VoiceEngine::startVoice is what resolves the real value —
|
||||
// sample.play.playRate is NOT read here, because the published block outranks the snapshot's
|
||||
// possibly-stale copy of it.
|
||||
//
|
||||
// `keyTrack` and `lengthFraction` are the other two members of stretchRate's note-on-latched
|
||||
// class and arrive the same way, for the same structural reason. Negative = not supplied,
|
||||
// which reads the snapshot's own value (sample.keyTrack, effectiveLengthFraction(play)) —
|
||||
// both are non-negative by domain, so the sentinel can never collide with a real one.
|
||||
// VoiceEngine::startVoice always supplies them, resolved from the published block when there
|
||||
// is one; the sentinel is for a caller that has no block to consult.
|
||||
void start(int note, int velocity, const SampleData& sample, bool declickTakeover = false,
|
||||
double stretchRate = 1.0);
|
||||
double stretchRate = 1.0, double keyTrack = kLatchFromSnapshot,
|
||||
double lengthFraction = kLatchFromSnapshot);
|
||||
|
||||
// Mono legato takeover: re-pitch this active voice to `note` without touching the
|
||||
// amplitude envelope, read position, or shifter state — pitch moves, no re-attack. Both
|
||||
@@ -201,7 +212,7 @@ private:
|
||||
// shifter's transpose. Cold: note-on, legato retune, and a live block, never per frame.
|
||||
void recomputeBaseRatio() {
|
||||
if (sample_ == nullptr) return;
|
||||
baseRatio_ = keyTrackedRatio(note_, sample_->rootNote, sample_->keyTrack) *
|
||||
baseRatio_ = keyTrackedRatio(note_, sample_->rootNote, keyTrack_) *
|
||||
velPitchRatio_ * pitchOffsetRatio_ * rateRatio_;
|
||||
}
|
||||
|
||||
@@ -710,6 +721,10 @@ private:
|
||||
double velPitchRatio_ = 1.0; // the velocity->pitch factor alone; retune re-applies it
|
||||
double pitchOffsetRatio_ = 1.0; // the Pitch knob's factor — LIVE, re-applied by applyLive
|
||||
double rateRatio_ = 1.0; // Rate's factor of the read increment; start() owns when it is 1
|
||||
// Key-track, LATCHED at note-on beside the rate. Held here rather than re-read off the
|
||||
// snapshot so a legato retune and a live block re-apply the note's own value; a published
|
||||
// move reaches the next note only.
|
||||
double keyTrack_ = kKeyTrackDefault;
|
||||
// Whether this note is ACTUALLY taking the Preserve read — a Preserve voice whose shifters
|
||||
// were never sized falls back to the varispeed one, and the two domains differ. Latched at
|
||||
// note-on beside rateRatio_, which start() resolves from the same predicate.
|
||||
|
||||
@@ -56,9 +56,14 @@ void VoiceEngine::startVoice(Voice& voice, int note, int velocity) {
|
||||
refreshLive();
|
||||
// THE read of the note-on-latched commit class, and the only one: a published block outranks
|
||||
// the snapshot's own copy (a live edit deliberately leaves that stale), and applyLive below
|
||||
// never touches the rate — so a Rate move reaches the next note and no sounding one.
|
||||
// touches none of these three — so a move reaches the next note and no sounding one.
|
||||
const double rate = haveLive_ ? live_.playRate : sample_.play.playRate;
|
||||
voice.start(note, velocity, sample_, /*declickTakeover=*/takeoverDeclick_, rate);
|
||||
const double keyTrack = haveLive_ ? live_.keyTrack : sample_.keyTrack;
|
||||
// Already spline-folded in the block; the snapshot branch folds here so the two agree.
|
||||
const double lengthFraction =
|
||||
haveLive_ ? live_.lengthFraction : effectiveLengthFraction(sample_.play);
|
||||
voice.start(note, velocity, sample_, /*declickTakeover=*/takeoverDeclick_, rate, keyTrack,
|
||||
lengthFraction);
|
||||
if (haveLive_) voice.applyLive(live_, /*snap=*/true);
|
||||
voice.setStartOrder(nextStartOrder_++);
|
||||
}
|
||||
|
||||
@@ -5,12 +5,25 @@
|
||||
// not link the bank model and the WAV codec to reach one value struct. `resolvePlay`, which
|
||||
// turns them into the engine's frame domain, stays in sample_map with the rest of the mapping.
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
#include "core/instrument/engine/play_params.h" // PlayMode / TriggerParams / SplineEnv / …
|
||||
|
||||
namespace reasampler::instrument::map {
|
||||
|
||||
using instrument::engine::VelocityCurve;
|
||||
|
||||
// THE seconds -> frames fold, and the one home for its rounding: resolvePlay resolves the whole
|
||||
// bundle through it, and the audio thread's live patch (param/param_live) resolves one stage
|
||||
// time through it, so a stage time can never land on a different frame depending on the writer.
|
||||
// A non-positive rate yields 0 rather than inventing one; a negative time floors at 0.
|
||||
inline std::int64_t secondsToFrames(double seconds, double sampleRate) {
|
||||
if (!(sampleRate > 0.0)) return 0;
|
||||
double f = seconds * sampleRate;
|
||||
if (!(f > 0.0)) return 0; // also catches NaN
|
||||
return static_cast<std::int64_t>(f + 0.5);
|
||||
}
|
||||
|
||||
// Daniel's standing ruling: no hardcoded sample rate anywhere in the program. The
|
||||
// instrument stores/edits wall-clock performance times (AHDSR A/H/D/R, pitch-env A/D) as
|
||||
// SECONDS, rate-free; the engine receives FRAMES resolved from the LIVE sample rate at
|
||||
|
||||
@@ -215,11 +215,7 @@ PlayParams resolvePlay(const PlaySeconds& stored, int sampleRate) {
|
||||
// carries through untouched, already a fraction.
|
||||
assert(sampleRate > 0 && "resolvePlay: sampleRate must be > 0 (programming error)");
|
||||
const double sr = sampleRate > 0 ? static_cast<double>(sampleRate) : 1.0; // 1.0 avoids div-by-zero; assert fires first
|
||||
const auto secToFrames = [sr](double sec) {
|
||||
double f = sec * sr;
|
||||
if (f < 0.0) f = 0.0;
|
||||
return static_cast<std::int64_t>(f + 0.5);
|
||||
};
|
||||
const auto secToFrames = [sr](double sec) { return secondsToFrames(sec, sr); };
|
||||
// The one seconds->frames fold for a stored AHD; the fraction and the curves are rate-free.
|
||||
const auto resolveAhd = [&secToFrames](const AhdSeconds& s) {
|
||||
AhdParams a;
|
||||
|
||||
@@ -180,7 +180,7 @@ struct InstrumentParams {
|
||||
// exactly 1.0, so already-saved instances are bit-identical. 0.0 = no tracking (every
|
||||
// key plays root pitch); 2.0 = double. Applied in keyTrackedRatio inside both repitch
|
||||
// engines.
|
||||
double keyTrack = 1.0;
|
||||
double keyTrack = kKeyTrackDefault;
|
||||
|
||||
// Velocity->amp transfer curve: maps note-on MIDI velocity (0..127) to voice amp gain,
|
||||
// replacing the old fixed linear velocity/127. Default = flat y=1 (Daniel-approved):
|
||||
@@ -215,7 +215,7 @@ struct InstrumentParams {
|
||||
struct ResolvedCapture {
|
||||
std::string relativePath; // project-relative; the shell resolves + decodes it
|
||||
int rootNote = 60; // effective: override, else bank intrinsic, else 60
|
||||
double keyTrack = 1.0;
|
||||
double keyTrack = kKeyTrackDefault;
|
||||
VelocityCurve velocityCurve = VelocityCurve::flat();
|
||||
SampleLoop loop; // effective: loopOverride, else bank intrinsic
|
||||
std::int64_t loopCrossfadeFrames = 0; // instrument-owned; no bank intrinsic to beat
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
# 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`), the one formatter per
|
||||
unit category, the host's own norm→stored write map, and the audio thread's block-boundary merge
|
||||
decision. 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.
|
||||
|
||||
**Where an exposed control's value lives is `valueHomeFor`'s answer, and the exposed set is
|
||||
asserted against it.** Two controls sit beside the parameter set rather than in it — master gain
|
||||
(the processor's atomic) and pitch key-track (`InstrumentParams::keyTrack`) — and a promotion
|
||||
whose control has no home would no-op silently in both directions on the host path with nothing
|
||||
to catch it at compile time. That is exactly what happened to id 1000 before the guard existed.
|
||||
|
||||
## Invariants
|
||||
|
||||
### The id table is FOREVER-FROZEN
|
||||
|
||||
`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`.
|
||||
- `param_live` — a host parameter write, BOTH sides of the model/audio split: `applyLiveParam`
|
||||
patches the live block in place (allocation-free, lock-free, for the `IParameterChanges` queue
|
||||
the SDK delivers on the audio thread, where the model layer cannot run — `resolvePlay`
|
||||
allocates), and `writeHostParam` lands the same write in the stored parameter set. One value
|
||||
map (`param_units`' `hostStoredFromNorm`) serves both, so they cannot disagree; the ROUTING is
|
||||
pinned by an exhaustive equivalence test between them over every exposed control. The routing
|
||||
switch carries **no `default:`** — a control promoted into the list without a route fails to
|
||||
compile, which the call site's discarded return value would otherwise hide.
|
||||
- `param_merge` — the audio thread's block-boundary merge DECISION, with no atomic and no host
|
||||
type in it: which held automation points still outrank the model, which the model has caught up
|
||||
on and are released, and whether an arriving point moves anything at all. It is the testable
|
||||
half of the AUTHORITY MODEL stated in `shell/instrument/CLAUDE.md`, and the reason both of that
|
||||
model's failure modes now have a test rather than a reviewer.
|
||||
|
||||
## 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.
|
||||
- **Both the host's read (`toPlain`) and write (`hostStoredFromNorm`) paths for a curve exponent
|
||||
skip `curve_law`'s knob detent, and that is deliberate** (Daniel, 2026-08-02: continuous ranges
|
||||
stay continuous at the host boundary). The detent is a DRAG affordance only — a drag grid
|
||||
delivers `start - dy/128` and lands on the identity only by luck, so a band wider than one drag
|
||||
step snaps to it — and a host lane has no grid. `curveFromKnobNorm` already answers exactly
|
||||
`1.0` at norm `0.5`, so skipping the detent costs nothing in reachability from the host. The
|
||||
dial-drag path (`ui::storedFromNorm`/its snap) is the one place the detented map still applies,
|
||||
because that is where the snap earns its place. `test_param_live`'s
|
||||
`testTheHostSkipsTheCurveDetentAndNothingElse` pins the write half; `test_param_format`'s
|
||||
`testAnOffDetentExponentReadsTrueToBothTheHostAndTheEditor` pins the read half.
|
||||
- **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
|
||||
editor additionally SUPPRESSES its unit suffix at that one value (`editor_controls`, the
|
||||
`Decibels` + non-finite test) — "-inf" rather than "-infdB", because there is no decibel value
|
||||
there. The host has no such hook and will render `ParameterInfo::units` beside it, so this is a
|
||||
deliberate ONE-VALUE break in the "editor digits + chrome == host digits + units" invariant
|
||||
stated above.
|
||||
- **MORPH ALONE can display one digit differently from a not-yet-stored norm.** The filter's four
|
||||
store their position as a `float`, but cutoff, Q and drive cast the incoming norm to `float`
|
||||
*inside* `toPlain`, so `toPlain(n)` and `toPlain(double(float(n)))` are bit-identical and those
|
||||
three are held to digit-for-digit string equality like everything else. Morph's path is
|
||||
full-double (`clamp01(n) * 100`), so the float the model stores and the double the host holds
|
||||
are genuinely different inputs — worth one integer percent at a value landing on a display
|
||||
rounding boundary. Both surfaces read the MODEL in every settled state, so it is a transient of
|
||||
the write itself, not a standing divergence; `test_param_format` holds morph alone to the plain
|
||||
value rather than to the string.
|
||||
- **A host write the MODEL clamps is not a settled state either.** Trigger length's stored
|
||||
domain is `(0,1]`, so a host norm of 0 comes back as 0.01. `setParamNormalized` caches what the
|
||||
model took, so the host never holds the rejected value — the sweep skips the clamped steps for
|
||||
that reason rather than loosening its comparison.
|
||||
@@ -0,0 +1,38 @@
|
||||
# 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. filter_params rather
|
||||
# than the whole `filter` target: this is the parameter surface, and a link edge from it onto the
|
||||
# per-voice filter KERNEL would put the voice DSP in reach of any future extension-side consumer
|
||||
# of param_format — which root CLAUDE.md's bake invariant forbids.
|
||||
reasampler_pure_library(param_units
|
||||
SOURCES param_units.cpp
|
||||
LINK PUBLIC deck_values param_taper curve_law master_gain filter_params)
|
||||
# sample_map for the test alone: the host-vs-editor default agreement reads the two instance
|
||||
# scalars where they LIVE, and one of them is a field of InstrumentParams.
|
||||
reasampler_test(param_units LINK param_units param_id sample_map)
|
||||
|
||||
reasampler_pure_library(param_format SOURCES param_format.cpp LINK PUBLIC param_units)
|
||||
# param_id and sample_map are linked for the test only: the one-formatter-two-consumers assertion
|
||||
# sweeps the exposed set (identity's answer, not this module's) and reads pitch key-track where it
|
||||
# lives, on InstrumentParams.
|
||||
reasampler_test(param_format LINK param_format param_id sample_map)
|
||||
|
||||
# The host write, both sides of the model/audio split: the live block patched in place and the
|
||||
# stored parameter set written, through one value map (param_units'). No engine — the block is a
|
||||
# value, not a thing the voice owns.
|
||||
reasampler_pure_library(param_live
|
||||
SOURCES param_live.cpp
|
||||
LINK PUBLIC deck_values live_params param_units)
|
||||
# sample_map for the test alone: the equivalence assertion drives the MODEL path
|
||||
# (writeHostParam -> resolvePlay -> foldLive) as its reference.
|
||||
reasampler_test(param_live LINK param_live param_id sample_map)
|
||||
|
||||
# The block-boundary merge decision — the automation hold's authority lifetime, with no atomic
|
||||
# and no host type in it.
|
||||
reasampler_pure_library(param_merge SOURCES param_merge.cpp LINK PUBLIC param_live)
|
||||
reasampler_test(param_merge LINK param_merge param_id param_units sample_map)
|
||||
@@ -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,126 @@
|
||||
// param_live.cpp — see param_live.h. ONE exhaustive routing switch and one shared value map;
|
||||
// every law is called, none is restated.
|
||||
|
||||
#include "core/instrument/param/param_live.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
#include "core/instrument/map/play_seconds.h" // secondsToFrames (resolvePlay's own fold)
|
||||
#include "core/instrument/param/param_units.h" // hostStoredFromNorm (the ONE host value map)
|
||||
#include "core/instrument/ui/deck_values.h" // the field resolvers setDeckParam writes through
|
||||
|
||||
namespace reasampler::instrument::param {
|
||||
|
||||
using engine::LiveValues;
|
||||
|
||||
bool applyLiveParam(LiveValues& block, DeckParam deck, double normalized, int sampleRate) {
|
||||
const double stored = hostStoredFromNorm(deck, normalized);
|
||||
const auto frames = [&](std::int64_t& dst) {
|
||||
dst = map::secondsToFrames(stored, static_cast<double>(sampleRate));
|
||||
return true;
|
||||
};
|
||||
const auto position = [&](float& dst) { dst = static_cast<float>(stored); return true; };
|
||||
const auto value = [&](double& dst) { dst = stored; return true; };
|
||||
|
||||
// NO `default:` — see the header. A promotion that forgets this file is a compile error.
|
||||
switch (deck) {
|
||||
// The fourteen stage times: stored seconds resolved at the BUILT rate.
|
||||
case DeckParam::kAttack: return frames(block.adsr.attackFrames);
|
||||
case DeckParam::kHold: return frames(block.adsr.holdFrames);
|
||||
case DeckParam::kDecay: return frames(block.adsr.decayFrames);
|
||||
case DeckParam::kRelease: return frames(block.adsr.releaseFrames);
|
||||
case DeckParam::kTrigAttack: return frames(block.ampAhd.attackFrames);
|
||||
case DeckParam::kTrigDecay: return frames(block.ampAhd.decayFrames);
|
||||
case DeckParam::kPitchEnvAttack: return frames(block.pitchEnv.shape.attackFrames);
|
||||
case DeckParam::kPitchEnvDecay: return frames(block.pitchEnv.shape.decayFrames);
|
||||
case DeckParam::kFilterEnvAttack: return frames(block.filterEnv.attackFrames);
|
||||
case DeckParam::kFilterEnvHold: return frames(block.filterEnv.holdFrames);
|
||||
case DeckParam::kFilterEnvDecay: return frames(block.filterEnv.decayFrames);
|
||||
case DeckParam::kFilterEnvRelease: return frames(block.filterEnv.releaseFrames);
|
||||
case DeckParam::kFilterTrigAttack: return frames(block.filterAhd.attackFrames);
|
||||
case DeckParam::kFilterTrigDecay: return frames(block.filterAhd.decayFrames);
|
||||
|
||||
// The filter's four, which store their normalized position as float exactly as the
|
||||
// parameter set stores it.
|
||||
case DeckParam::kFilterMorph: return position(block.filterSettings.morphNorm);
|
||||
case DeckParam::kFilterCutoff: return position(block.filterSettings.cutoffNorm);
|
||||
case DeckParam::kFilterQ: return position(block.filterSettings.resonanceNorm);
|
||||
case DeckParam::kFilterDrive: return position(block.filterSettings.driveNorm);
|
||||
|
||||
// Everything the block carries verbatim as a double.
|
||||
case DeckParam::kSustain: return value(block.adsr.sustainLevel);
|
||||
case DeckParam::kAttackCurve: return value(block.adsr.attackCurve);
|
||||
case DeckParam::kDecayCurve: return value(block.adsr.decayCurve);
|
||||
case DeckParam::kReleaseCurve: return value(block.adsr.releaseCurve);
|
||||
case DeckParam::kTrigHold: return value(block.ampAhd.holdFraction);
|
||||
case DeckParam::kTrigAttackCurve: return value(block.ampAhd.attackCurve);
|
||||
case DeckParam::kTrigDecayCurve: return value(block.ampAhd.decayCurve);
|
||||
case DeckParam::kPitchEnvHold: return value(block.pitchEnv.shape.holdFraction);
|
||||
case DeckParam::kPitchEnvAttackCurve: return value(block.pitchEnv.shape.attackCurve);
|
||||
case DeckParam::kPitchEnvDecayCurve: return value(block.pitchEnv.shape.decayCurve);
|
||||
case DeckParam::kPitchEnvDepth: return value(block.pitchEnv.peakSemitones);
|
||||
case DeckParam::kFilterEnvSustain: return value(block.filterEnv.sustainLevel);
|
||||
case DeckParam::kFilterEnvAttackCurve: return value(block.filterEnv.attackCurve);
|
||||
case DeckParam::kFilterEnvDecayCurve: return value(block.filterEnv.decayCurve);
|
||||
case DeckParam::kFilterEnvReleaseCurve: return value(block.filterEnv.releaseCurve);
|
||||
case DeckParam::kFilterTrigHold: return value(block.filterAhd.holdFraction);
|
||||
case DeckParam::kFilterTrigAttackCurve: return value(block.filterAhd.attackCurve);
|
||||
case DeckParam::kFilterTrigDecayCurve: return value(block.filterAhd.decayCurve);
|
||||
case DeckParam::kFilterModAmt: return value(block.filterModAmount);
|
||||
case DeckParam::kFilterVel: return value(block.filterVelAmount);
|
||||
case DeckParam::kFilterKeyTrack: return value(block.filterKeyTrack);
|
||||
case DeckParam::kRate: return value(block.playRate);
|
||||
case DeckParam::kPitch: return value(block.pitchOffsetSemitones);
|
||||
case DeckParam::kKeyTrack: return value(block.keyTrack);
|
||||
|
||||
// The one control the block does not carry verbatim: what it publishes is the
|
||||
// SPLINE-FOLDED fraction, so a write while a contour is active must be inert here for the
|
||||
// same reason the knob is inert in the editor.
|
||||
case DeckParam::kTrigLength:
|
||||
if (!block.splineActive) block.lengthFraction = stored;
|
||||
return true;
|
||||
|
||||
// Not carried. Master gain reaches the audio beside the block, as the processor's own
|
||||
// atomic; the rest are toggles, radios, curve-popup cells and the deck's processor-side
|
||||
// controls — all Reload- or rebuild-tier, so none of them is an exposed parameter.
|
||||
case DeckParam::kMasterGain:
|
||||
case DeckParam::kPlayMode:
|
||||
case DeckParam::kPitchEngine:
|
||||
case DeckParam::kPitchEnvEnable:
|
||||
case DeckParam::kFilterEnable:
|
||||
case DeckParam::kFilterLaw:
|
||||
case DeckParam::kAmpVelCurve:
|
||||
case DeckParam::kPitchVelCurve:
|
||||
case DeckParam::kFilterVelCurve:
|
||||
case DeckParam::kAmpEnvSelect:
|
||||
case DeckParam::kPitchEnvSelect:
|
||||
case DeckParam::kFilterEnvSelect:
|
||||
case DeckParam::kAmpEnvMode:
|
||||
case DeckParam::kPitchEnvMode:
|
||||
case DeckParam::kFilterEnvMode:
|
||||
case DeckParam::kVoiceCount:
|
||||
case DeckParam::kVoiceMode:
|
||||
case DeckParam::kMonoTrigger:
|
||||
case DeckParam::kLimiterEnable:
|
||||
case DeckParam::kMasterMeter:
|
||||
case DeckParam::kMasterGr:
|
||||
case DeckParam::kCount:
|
||||
return false;
|
||||
}
|
||||
return false; // unreachable for a valid enumerator; silences a warning.
|
||||
}
|
||||
|
||||
bool writeHostParam(DeckParam deck, map::PlaySeconds& play, double normalized) {
|
||||
const double stored = hostStoredFromNorm(deck, normalized);
|
||||
if (float* f = ui::deckFloatField(deck, play)) {
|
||||
*f = static_cast<float>(stored);
|
||||
return true;
|
||||
}
|
||||
if (double* d = ui::deckDoubleField(deck, play)) {
|
||||
*d = stored;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::param
|
||||
@@ -0,0 +1,41 @@
|
||||
// param_live.h — a host parameter write landed on BOTH sides of the model/audio split: into the
|
||||
// live block in place (RT-safe, for `IParameterChanges`, which the SDK delivers on the audio
|
||||
// thread where the model path cannot run — `resolvePlay` allocates), and into the stored
|
||||
// parameter set. One norm -> stored map serves both, so they cannot disagree.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "core/instrument/engine/live_params.h"
|
||||
#include "core/instrument/map/play_seconds.h" // PlaySeconds (the model-side write target)
|
||||
#include "core/instrument/ui/deck_groups.h" // DeckParam
|
||||
|
||||
namespace reasampler::instrument::param {
|
||||
|
||||
using ui::DeckParam;
|
||||
|
||||
// Writes `normalized` for `deck` into `block`. RT-SAFE: no allocation, no lock, no transcendental
|
||||
// beyond the taper's own. Returns false for a control this block does not carry — master gain,
|
||||
// which reaches the audio as the processor's own atomic, and anything unexposed.
|
||||
//
|
||||
// The value laws are NOT restated here: `hostStoredFromNorm` is the same norm -> stored map the
|
||||
// model-side write below takes, and `map::secondsToFrames` the same fold `resolvePlay` uses. What
|
||||
// IS new is the routing — which member of the block a control names — and its switch carries no
|
||||
// `default:`, so a control promoted into the parameter list without a route here fails to COMPILE
|
||||
// rather than dropping its automation silently at a call site that discards the answer.
|
||||
//
|
||||
// `sampleRate` is the rate the loaded capture was BUILT at (the processor's builtSampleRate_),
|
||||
// so a patched stage time lands on exactly the frames the build would have resolved.
|
||||
bool applyLiveParam(engine::LiveValues& block, DeckParam deck, double normalized, int sampleRate);
|
||||
|
||||
// The MODEL-side peer: the same host write, landed in the stored parameter set instead. Sharing
|
||||
// `hostStoredFromNorm` and the field resolvers with the patch above is what makes the equivalence
|
||||
// test's claim — patch == fold-after-write — a property of one map rather than of two that agree.
|
||||
// False for a control PlaySeconds does not carry: the two instance scalars (master gain, pitch
|
||||
// key-track) are written where they live, by the shell.
|
||||
//
|
||||
// No `enforceGateUnavailableWhileDrawn` here, unlike `ui::setDeckParam`: every control that can
|
||||
// flip `splineActive` is a toggle, every toggle is Reload-tier, and no Reload-tier control is
|
||||
// exposed — so nothing reachable from a host write can open that hole.
|
||||
bool writeHostParam(DeckParam deck, map::PlaySeconds& play, double normalized);
|
||||
|
||||
} // namespace reasampler::instrument::param
|
||||
@@ -0,0 +1,25 @@
|
||||
// param_merge.cpp — see param_merge.h.
|
||||
|
||||
#include "core/instrument/param/param_merge.h"
|
||||
|
||||
#include "core/instrument/param/param_live.h"
|
||||
|
||||
namespace reasampler::instrument::param {
|
||||
|
||||
void mergeAutomation(engine::LiveValues& block, AutomationSlot* slots, std::size_t count,
|
||||
int sampleRate) {
|
||||
for (std::size_t i = 0; i < count; ++i) {
|
||||
AutomationSlot& slot = slots[i];
|
||||
if (!slot.held) continue;
|
||||
if (slot.folded) {
|
||||
// Nothing to patch: `block` was read from the model, and the model is what the fold
|
||||
// wrote this point into. Dropping the hold here is the whole release.
|
||||
slot.held = false;
|
||||
slot.folded = false;
|
||||
continue;
|
||||
}
|
||||
applyLiveParam(block, static_cast<DeckParam>(i), slot.norm, sampleRate);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::param
|
||||
@@ -0,0 +1,51 @@
|
||||
// param_merge.h — the audio thread's block-boundary merge DECISION, with no host type and no
|
||||
// atomic in it: which held automation points still outrank the model, which the model has caught
|
||||
// up on and are released, and whether the result is worth republishing. The AUTHORITY MODEL it
|
||||
// implements is stated in `shell/instrument/CLAUDE.md`; this is its testable half.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
#include "core/instrument/engine/live_params.h"
|
||||
#include "core/instrument/ui/deck_groups.h" // DeckParam (the ordinal space slots are indexed by)
|
||||
|
||||
namespace reasampler::instrument::param {
|
||||
|
||||
using ui::DeckParam;
|
||||
|
||||
// The DeckParam ordinal space. One slot per control, indexed by ordinal, so a lookup is an index
|
||||
// rather than a search on the audio thread.
|
||||
inline constexpr std::size_t kDeckParamSlots = static_cast<std::size_t>(DeckParam::kCount);
|
||||
|
||||
// One control's automation state as the merge sees it.
|
||||
struct AutomationSlot {
|
||||
double norm = 0.0; // the last point this lane delivered
|
||||
bool held = false; // that point still outranks the model
|
||||
bool folded = false; // the model has since been rewritten to carry THAT point
|
||||
};
|
||||
|
||||
// Patches every still-held slot over `block`, and RELEASES each slot the model has caught up on.
|
||||
//
|
||||
// The release is what BOUNDS a point's authority. A lane outranks a plug-in-side set only while
|
||||
// it is driving; a value it delivered once, already folded back into the model, outranks nothing.
|
||||
// Without the release a single automation point would defeat every later state restore, bake
|
||||
// reset and knob move for the life of the instance — which is the failure this function exists
|
||||
// to make impossible, and which `test_param_merge` is the test of.
|
||||
//
|
||||
// RT-SAFE: no allocation, no lock, no transcendental beyond the tapers' own.
|
||||
void mergeAutomation(engine::LiveValues& block, AutomationSlot* slots, std::size_t count,
|
||||
int sampleRate);
|
||||
|
||||
// Whether a point of `normalized` for a slot in this state actually moves the block. False for a
|
||||
// point equal to a hold that is still standing — the ordinary read-mode steady state, where a
|
||||
// host delivers one point per block over a flat lane segment. Republishing there would drive
|
||||
// `VoiceEngine::applyLiveToActive` over every sounding voice — a `std::pow`, two envelope φ
|
||||
// re-fits and the filter ramp aims, per voice — for a value that did not move. Once the hold has
|
||||
// been RELEASED the answer is true again, because some other writer may have moved the model
|
||||
// since.
|
||||
inline bool automationPointMoves(const AutomationSlot& slot, double normalized) {
|
||||
return !slot.held || slot.norm != normalized;
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::param
|
||||
@@ -0,0 +1,304 @@
|
||||
// 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;
|
||||
// The twelve curve exponents and the filter's two dimensionless tone controls. Listed
|
||||
// rather than defaulted, and everything with no parameter row at all is listed with
|
||||
// them: a `default:` here would let a control promoted later inherit Dimensionless
|
||||
// silently, and an exposed parameter's normalization is frozen on the first shipped
|
||||
// build — so the wrong answer would be permanent rather than correctable. THIS switch is
|
||||
// the one that has to be exhaustive; the `default:` arms further down are pre-dispatch
|
||||
// filters that fall through to it, so they inherit its exhaustiveness rather than
|
||||
// needing their own.
|
||||
case DeckParam::kFilterQ:
|
||||
case DeckParam::kFilterDrive:
|
||||
case DeckParam::kAttackCurve:
|
||||
case DeckParam::kDecayCurve:
|
||||
case DeckParam::kReleaseCurve:
|
||||
case DeckParam::kTrigAttackCurve:
|
||||
case DeckParam::kTrigDecayCurve:
|
||||
case DeckParam::kPitchEnvAttackCurve:
|
||||
case DeckParam::kPitchEnvDecayCurve:
|
||||
case DeckParam::kFilterEnvAttackCurve:
|
||||
case DeckParam::kFilterEnvDecayCurve:
|
||||
case DeckParam::kFilterEnvReleaseCurve:
|
||||
case DeckParam::kFilterTrigAttackCurve:
|
||||
case DeckParam::kFilterTrigDecayCurve:
|
||||
case DeckParam::kPlayMode:
|
||||
case DeckParam::kPitchEngine:
|
||||
case DeckParam::kPitchEnvEnable:
|
||||
case DeckParam::kFilterEnable:
|
||||
case DeckParam::kFilterLaw:
|
||||
case DeckParam::kAmpVelCurve:
|
||||
case DeckParam::kPitchVelCurve:
|
||||
case DeckParam::kFilterVelCurve:
|
||||
case DeckParam::kAmpEnvSelect:
|
||||
case DeckParam::kPitchEnvSelect:
|
||||
case DeckParam::kFilterEnvSelect:
|
||||
case DeckParam::kAmpEnvMode:
|
||||
case DeckParam::kPitchEnvMode:
|
||||
case DeckParam::kFilterEnvMode:
|
||||
case DeckParam::kVoiceCount:
|
||||
case DeckParam::kVoiceMode:
|
||||
case DeckParam::kMonoTrigger:
|
||||
case DeckParam::kLimiterEnable:
|
||||
case DeckParam::kMasterMeter:
|
||||
case DeckParam::kMasterGr:
|
||||
return UnitKind::Dimensionless;
|
||||
// The sentinel, on its own arm: it names no control, so its unit string, plain range and
|
||||
// toPlain law are all arbitrary. It is here only because the switch is exhaustive, and
|
||||
// it stays out of the run above so that run reads as a list of real controls.
|
||||
case DeckParam::kCount:
|
||||
return UnitKind::Dimensionless;
|
||||
}
|
||||
return UnitKind::Dimensionless; // unreachable for a valid enumerator; silences a warning.
|
||||
}
|
||||
|
||||
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:
|
||||
// Undetented: a host-facing continuous range stays continuous (Daniel, 2026-08-02) —
|
||||
// the detent is a drag affordance, not part of the value law. See hostStoredFromNorm.
|
||||
return util::curveFromKnobNormUndetented(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;
|
||||
}
|
||||
|
||||
double hostStoredFromNorm(DeckParam deck, double normalized) {
|
||||
// See the header for why the detent is a drag affordance and not part of the value law.
|
||||
if (ui::deckParamUnit(deck) == ui::UnitCategory::Exponent) {
|
||||
return util::curveFromKnobNormUndetented(normalized);
|
||||
}
|
||||
return ui::storedFromNorm(deck, normalized);
|
||||
}
|
||||
|
||||
ValueHome valueHomeFor(DeckParam deck) {
|
||||
PlaySeconds defaults;
|
||||
if (ui::deckFloatField(deck, defaults)) return ValueHome::ParamSetNorm;
|
||||
if (ui::deckDoubleField(deck, defaults)) return ValueHome::ParamSet;
|
||||
if (deck == DeckParam::kMasterGain || deck == DeckParam::kKeyTrack) {
|
||||
return ValueHome::InstanceScalar;
|
||||
}
|
||||
return ValueHome::None;
|
||||
}
|
||||
|
||||
bool storesNormalized(DeckParam deck) {
|
||||
return valueHomeFor(deck) == ValueHome::ParamSetNorm;
|
||||
}
|
||||
|
||||
double defaultPlain(DeckParam deck) {
|
||||
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));
|
||||
}
|
||||
// The two instance scalars, whose default is not a field of PlaySeconds.
|
||||
if (deck == DeckParam::kMasterGain) return 0.0; // unity, and the sharpest exactness case
|
||||
if (deck == DeckParam::kKeyTrack) {
|
||||
return kKeyTrackDefault * kPercentFullScale;
|
||||
}
|
||||
const double* field = ui::deckDoubleField(deck, defaults);
|
||||
if (!field) return 0.0;
|
||||
switch (unitKindFor(deck)) {
|
||||
// Time converts seconds -> ms here and ms -> seconds in toNormalized, so its exactness
|
||||
// additionally rests on x*1000/1000 == x — param_taper guarantees its quantum in SECONDS,
|
||||
// not in ms. It holds for today's three Time defaults; a new one is a case to re-check.
|
||||
case UnitKind::Time: return *field * 1000.0; // stored seconds
|
||||
case UnitKind::PercentUnipolar: return *field * kPercentFullScale;
|
||||
case UnitKind::PercentKeyTrack: return *field * kPercentFullScale; // stored 0..2
|
||||
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,85 @@
|
||||
// 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 STORED value a host write of `normalized` lands on — `ui::storedFromNorm` for every
|
||||
// control except the twelve curve exponents, where the knob law's ±0.01 detent is skipped. That
|
||||
// detent is a DRAG affordance: a drag grid lands on the identity only by luck, so a band wider
|
||||
// than one drag step snaps to it. A host lane has no grid and `curveFromKnobNorm` already
|
||||
// answers exactly 1.0 at norm 0.5, so applying the detent here would not make anything
|
||||
// reachable — it would flatten a knot-drawn near-neutral exponent to 1.0 on any lane pass.
|
||||
// BOTH host write paths take this map (the model's `writeHostParam`, the audio thread's
|
||||
// `applyLiveParam`), which is what keeps them from landing different values in the same block.
|
||||
double hostStoredFromNorm(DeckParam deck, double normalized);
|
||||
|
||||
// WHERE a control's value actually lives. The host's read and write paths branch on this, and
|
||||
// the exposed set is asserted against it: a control promoted into the list with no home would
|
||||
// otherwise no-op silently in BOTH directions, with nothing to catch it at compile time.
|
||||
enum class ValueHome {
|
||||
None, // not a scalar control at all — a toggle, a radio, a curve-popup cell
|
||||
ParamSetNorm, // the filter's four: the stored double IS the normalized position
|
||||
ParamSet, // every other knob the parameter set carries
|
||||
InstanceScalar, // beside the parameter set: master gain, and the pitch key-track scalar
|
||||
};
|
||||
ValueHome valueHomeFor(DeckParam deck);
|
||||
|
||||
// The filter's four tone controls STORE their normalized position (payload v9), so their default
|
||||
// normalized value is that stored double verbatim and no taper participates in a host's
|
||||
// reset-to-default. Reporting Hz / Q / drive depth for them means CALLING their frozen laws, not
|
||||
// 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
|
||||
@@ -201,9 +201,16 @@ DeckParam curveParamFor(DeckParam knob) {
|
||||
|
||||
LiveCommit deckParamCommit(DeckParam id) {
|
||||
switch (id) {
|
||||
// The one note-on-latched control; the header owns why.
|
||||
// The note-on-latched controls; the header owns why each one latches.
|
||||
case DeckParam::kRate:
|
||||
case DeckParam::kKeyTrack:
|
||||
case DeckParam::kTrigLength:
|
||||
return LiveCommit::NoteOnLatched;
|
||||
// Live by the tier's own definition — one atomic store the audio thread picks up at the
|
||||
// next block, no bridge read and no re-decode. It reaches the audio beside the live
|
||||
// block rather than through it, which is why it carries no LiveValues field; that is a
|
||||
// question of ROUTE, and this predicate answers TIER.
|
||||
case DeckParam::kMasterGain:
|
||||
case DeckParam::kPitch:
|
||||
case DeckParam::kAttack:
|
||||
case DeckParam::kHold:
|
||||
@@ -251,9 +258,7 @@ LiveCommit deckParamCommit(DeckParam id) {
|
||||
// to non-live. Reasons live in the header.
|
||||
case DeckParam::kPlayMode:
|
||||
case DeckParam::kPitchEngine:
|
||||
case DeckParam::kTrigLength:
|
||||
case DeckParam::kPitchEnvEnable:
|
||||
case DeckParam::kKeyTrack:
|
||||
case DeckParam::kFilterEnable:
|
||||
case DeckParam::kAmpVelCurve:
|
||||
case DeckParam::kPitchVelCurve:
|
||||
@@ -270,7 +275,6 @@ LiveCommit deckParamCommit(DeckParam id) {
|
||||
case DeckParam::kVoiceCount:
|
||||
case DeckParam::kVoiceMode:
|
||||
case DeckParam::kMonoTrigger:
|
||||
case DeckParam::kMasterGain:
|
||||
case DeckParam::kLimiterEnable:
|
||||
// MASTER's two readouts reach no parameter at all — the same footing as the overlay
|
||||
// radios above.
|
||||
|
||||
@@ -164,11 +164,19 @@ DeckParam curveParamFor(DeckParam knob);
|
||||
// Both amp shapes are live: the Trigger fade pair that used to reload folded into the AHD and
|
||||
// inherited its routing, so a Trigger-mode instance now tracks its amplitude knobs too.
|
||||
//
|
||||
// kRate is the one NoteOnLatched control, and the reason is a real feature rather than a
|
||||
// plumbing detail: loop points and contours both scale with rate, and both are note-on folds —
|
||||
// resolveLoop runs once per note-on and a contour resolves against the note's own span. A live
|
||||
// rate would mean re-folding an already-resolved loop and re-mapping a contour mid-note without
|
||||
// a discontinuity. kPitch is not implicated and is ordinarily Live.
|
||||
// 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.
|
||||
//
|
||||
// THREE controls are NoteOnLatched: kRate, kKeyTrack and kTrigLength. The exclusions list above
|
||||
// already gives the latter two their reason — both were Reload until they were promoted so they
|
||||
// could be automated at all, since a reload per automation point re-decodes the WAV. kRate's
|
||||
// reason is its own, and is a real feature rather than a plumbing detail: loop points and
|
||||
// contours both scale with rate, and both are note-on folds — resolveLoop runs once per note-on
|
||||
// and a contour resolves against the note's own span. A live rate would mean re-folding an
|
||||
// already-resolved loop and re-mapping a contour mid-note without a discontinuity. kPitch is not
|
||||
// implicated and is ordinarily Live.
|
||||
enum class LiveCommit { Live, NoteOnLatched, Reload };
|
||||
LiveCommit deckParamCommit(DeckParam id);
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
|
||||
#include "core/instrument/engine/filter/filter_morph.h" // MorphLaw (the law toggle's value)
|
||||
#include "core/instrument/engine/master_gain.h" // the dB taper the whole-dB snap reads
|
||||
@@ -64,7 +63,7 @@ double deckParamNorm(DeckParam id, const PlaySeconds& play) {
|
||||
case DeckParam::kFilterDrive: return clamp01(play.filter.settings.driveNorm);
|
||||
case DeckParam::kFilterModAmt: return deckNormFromBipolar(play.filter.modAmount);
|
||||
case DeckParam::kFilterVel: return deckNormFromBipolar(play.filter.velAmount);
|
||||
case DeckParam::kFilterKeyTrack: return clamp01(play.filter.keyTrack / kKeyTrackMax);
|
||||
case DeckParam::kFilterKeyTrack: return keyTrackNormFrom(play.filter.keyTrack);
|
||||
case DeckParam::kFilterEnvAttack: return timeNormFromSeconds(play.filter.env.attackSeconds);
|
||||
case DeckParam::kFilterEnvHold: return timeNormFromSeconds(play.filter.env.holdSeconds);
|
||||
case DeckParam::kFilterEnvDecay: return timeNormFromSeconds(play.filter.env.decaySeconds);
|
||||
@@ -90,6 +89,49 @@ double deckParamNorm(DeckParam id, const PlaySeconds& play) {
|
||||
}
|
||||
}
|
||||
|
||||
double storedFromNorm(DeckParam id, double norm) {
|
||||
switch (id) {
|
||||
// The filter's four STORE their normalized position (payload v9), so the identity IS
|
||||
// their law — deckFloatField's four, and the reason it is a separate resolver.
|
||||
case DeckParam::kFilterMorph:
|
||||
case DeckParam::kFilterCutoff:
|
||||
case DeckParam::kFilterQ:
|
||||
case DeckParam::kFilterDrive:
|
||||
return clamp01(norm);
|
||||
case DeckParam::kRate:
|
||||
return rateRatioFromNorm(norm, kRateMinRatio, kRateMaxRatio);
|
||||
case DeckParam::kPitch:
|
||||
case DeckParam::kPitchEnvDepth:
|
||||
return depthSemitonesFromNorm(norm, kPitchDepthMaxSemis);
|
||||
case DeckParam::kFilterModAmt:
|
||||
case DeckParam::kFilterVel:
|
||||
return deckBipolarFromNorm(norm);
|
||||
case DeckParam::kKeyTrack:
|
||||
case DeckParam::kFilterKeyTrack:
|
||||
return keyTrackFromNorm(norm);
|
||||
case DeckParam::kTrigLength:
|
||||
// lengthFraction is (0,1]; a small floor so a zero-length trigger never plays
|
||||
// nothing.
|
||||
return (std::max)(0.01, clamp01(norm));
|
||||
default:
|
||||
break;
|
||||
}
|
||||
// The rest are decided by the display unit alone, which is what makes the fourteen stage
|
||||
// times and the twelve curve dials one line each rather than twenty-six.
|
||||
switch (deckParamUnit(id)) {
|
||||
case UnitCategory::Milliseconds: return timeSecondsFromNorm(norm);
|
||||
case UnitCategory::Exponent: return util::curveFromKnobNorm(norm);
|
||||
case UnitCategory::Percent: return clamp01(norm); // hold fractions, sustain levels
|
||||
// Decibels is master gain, whose stored value is a LINEAR gain the processor owns
|
||||
// rather than a field of the parameter set; the two enums above are handled by id.
|
||||
case UnitCategory::Semitones:
|
||||
case UnitCategory::Decibels:
|
||||
case UnitCategory::None:
|
||||
break;
|
||||
}
|
||||
return norm;
|
||||
}
|
||||
|
||||
void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment) {
|
||||
switch (id) {
|
||||
case DeckParam::kPlayMode:
|
||||
@@ -113,88 +155,24 @@ void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment) {
|
||||
case DeckParam::kPitchEngine:
|
||||
play.pitchEngine = (segment == 1) ? PitchEngine::Preserve : PitchEngine::Varispeed;
|
||||
break;
|
||||
case DeckParam::kRate:
|
||||
play.playRate = rateRatioFromNorm(value, kRateMinRatio, kRateMaxRatio); break;
|
||||
case DeckParam::kPitch:
|
||||
play.pitchOffsetSemitones = depthSemitonesFromNorm(value, kPitchDepthMaxSemis); break;
|
||||
case DeckParam::kAttack: play.adsr.attackSeconds = timeSecondsFromNorm(value); break;
|
||||
case DeckParam::kHold: play.adsr.holdSeconds = timeSecondsFromNorm(value); break;
|
||||
case DeckParam::kDecay: play.adsr.decaySeconds = timeSecondsFromNorm(value); break;
|
||||
case DeckParam::kSustain: play.adsr.sustainLevel = clamp01(value); break;
|
||||
case DeckParam::kRelease: play.adsr.releaseSeconds = timeSecondsFromNorm(value); break;
|
||||
case DeckParam::kAttackCurve: play.adsr.attackCurve = util::curveFromKnobNorm(value); break;
|
||||
case DeckParam::kDecayCurve: play.adsr.decayCurve = util::curveFromKnobNorm(value); break;
|
||||
case DeckParam::kReleaseCurve: play.adsr.releaseCurve = util::curveFromKnobNorm(value); break;
|
||||
case DeckParam::kTrigLength:
|
||||
// lengthFraction is (0,1]; keep a small floor so a zero-length trigger never plays
|
||||
// nothing.
|
||||
play.trigger.lengthFraction = (std::max)(0.01, clamp01(value));
|
||||
break;
|
||||
case DeckParam::kTrigAttack: play.trigAhd.attackSeconds = timeSecondsFromNorm(value); break;
|
||||
case DeckParam::kTrigHold: play.trigAhd.holdFraction = clamp01(value); break;
|
||||
case DeckParam::kTrigDecay: play.trigAhd.decaySeconds = timeSecondsFromNorm(value); break;
|
||||
case DeckParam::kTrigAttackCurve:
|
||||
play.trigAhd.attackCurve = util::curveFromKnobNorm(value); break;
|
||||
case DeckParam::kTrigDecayCurve:
|
||||
play.trigAhd.decayCurve = util::curveFromKnobNorm(value); break;
|
||||
case DeckParam::kPitchEnvEnable: play.pitchEnv.enabled = (segment == 1); break;
|
||||
case DeckParam::kPitchEnvAttack:
|
||||
play.pitchEnv.shape.attackSeconds = timeSecondsFromNorm(value); break;
|
||||
case DeckParam::kPitchEnvHold:
|
||||
play.pitchEnv.shape.holdFraction = clamp01(value); break;
|
||||
case DeckParam::kPitchEnvDecay:
|
||||
play.pitchEnv.shape.decaySeconds = timeSecondsFromNorm(value); break;
|
||||
case DeckParam::kPitchEnvAttackCurve:
|
||||
play.pitchEnv.shape.attackCurve = util::curveFromKnobNorm(value); break;
|
||||
case DeckParam::kPitchEnvDecayCurve:
|
||||
play.pitchEnv.shape.decayCurve = util::curveFromKnobNorm(value); break;
|
||||
case DeckParam::kPitchEnvDepth:
|
||||
play.pitchEnv.peakSemitones = depthSemitonesFromNorm(value, kPitchDepthMaxSemis);
|
||||
break;
|
||||
case DeckParam::kFilterEnable: play.filter.enabled = (segment == 1); break;
|
||||
case DeckParam::kFilterLaw:
|
||||
play.filter.settings.morphLaw =
|
||||
(segment == 1) ? MorphLaw::HighNotchLow : MorphLaw::HighBandLow;
|
||||
break;
|
||||
case DeckParam::kFilterMorph:
|
||||
play.filter.settings.morphNorm = static_cast<float>(clamp01(value)); break;
|
||||
case DeckParam::kFilterCutoff:
|
||||
play.filter.settings.cutoffNorm = static_cast<float>(clamp01(value)); break;
|
||||
case DeckParam::kFilterQ:
|
||||
play.filter.settings.resonanceNorm = static_cast<float>(clamp01(value)); break;
|
||||
case DeckParam::kFilterDrive:
|
||||
play.filter.settings.driveNorm = static_cast<float>(clamp01(value)); break;
|
||||
case DeckParam::kFilterModAmt: play.filter.modAmount = deckBipolarFromNorm(value); break;
|
||||
case DeckParam::kFilterVel: play.filter.velAmount = deckBipolarFromNorm(value); break;
|
||||
case DeckParam::kFilterKeyTrack:
|
||||
play.filter.keyTrack = clamp01(value) * kKeyTrackMax; break;
|
||||
case DeckParam::kFilterEnvAttack:
|
||||
play.filter.env.attackSeconds = timeSecondsFromNorm(value); break;
|
||||
case DeckParam::kFilterEnvHold:
|
||||
play.filter.env.holdSeconds = timeSecondsFromNorm(value); break;
|
||||
case DeckParam::kFilterEnvDecay:
|
||||
play.filter.env.decaySeconds = timeSecondsFromNorm(value); break;
|
||||
case DeckParam::kFilterEnvSustain:
|
||||
play.filter.env.sustainLevel = clamp01(value); break;
|
||||
case DeckParam::kFilterEnvRelease:
|
||||
play.filter.env.releaseSeconds = timeSecondsFromNorm(value); break;
|
||||
case DeckParam::kFilterEnvAttackCurve:
|
||||
play.filter.env.attackCurve = util::curveFromKnobNorm(value); break;
|
||||
case DeckParam::kFilterEnvDecayCurve:
|
||||
play.filter.env.decayCurve = util::curveFromKnobNorm(value); break;
|
||||
case DeckParam::kFilterEnvReleaseCurve:
|
||||
play.filter.env.releaseCurve = util::curveFromKnobNorm(value); break;
|
||||
case DeckParam::kFilterTrigAttack:
|
||||
play.filter.trigEnv.attackSeconds = timeSecondsFromNorm(value); break;
|
||||
case DeckParam::kFilterTrigHold:
|
||||
play.filter.trigEnv.holdFraction = clamp01(value); break;
|
||||
case DeckParam::kFilterTrigDecay:
|
||||
play.filter.trigEnv.decaySeconds = timeSecondsFromNorm(value); break;
|
||||
case DeckParam::kFilterTrigAttackCurve:
|
||||
play.filter.trigEnv.attackCurve = util::curveFromKnobNorm(value); break;
|
||||
case DeckParam::kFilterTrigDecayCurve:
|
||||
play.filter.trigEnv.decayCurve = util::curveFromKnobNorm(value); break;
|
||||
default: break;
|
||||
default:
|
||||
// Every knob: the one norm -> stored law, into the one field the control names.
|
||||
// Both halves are shared with the audio thread's live patch (param/param_live), so
|
||||
// a control cannot take a different taper or land in a different field depending on
|
||||
// which surface wrote it. A toggle or a value living outside PlaySeconds resolves to
|
||||
// neither field and falls through untouched.
|
||||
if (float* f = deckFloatField(id, play)) {
|
||||
*f = static_cast<float>(storedFromNorm(id, value));
|
||||
} else if (double* d = deckDoubleField(id, play)) {
|
||||
*d = storedFromNorm(id, value);
|
||||
}
|
||||
break;
|
||||
}
|
||||
// ONE normalization point for every control that can flip splineActive — a mode toggle
|
||||
// (above) or an enable toggle (kPitchEnvEnable/kFilterEnable), whose enabling can make an
|
||||
@@ -363,8 +341,8 @@ double snapDeckParamNorm(DeckParam id, double norm) {
|
||||
snapFractionToWholePercent(deckBipolarFromNorm(norm)));
|
||||
case DeckParam::kKeyTrack:
|
||||
case DeckParam::kFilterKeyTrack:
|
||||
return clamp01(
|
||||
snapFractionToWholePercent(clamp01(norm) * kKeyTrackMax) / kKeyTrackMax);
|
||||
return keyTrackNormFrom(
|
||||
snapFractionToWholePercent(keyTrackFromNorm(norm)));
|
||||
default:
|
||||
return clamp01(snapFractionToWholePercent(clamp01(norm)));
|
||||
}
|
||||
@@ -374,10 +352,4 @@ double snapDeckParamNorm(DeckParam id, double norm) {
|
||||
return norm;
|
||||
}
|
||||
|
||||
void formatEnvTimeMs(double seconds, char* buf, std::size_t len) {
|
||||
if (!buf || len == 0) return;
|
||||
const double ms = seconds * 1000.0;
|
||||
std::snprintf(buf, len, ms < 10.0 ? "%.1f ms" : "%.0f ms", ms);
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
// deck_values.h — the deck's control-id <-> parameter-set BINDING and its display units: the
|
||||
// normalized 0..1 a knob shows, the write back into the stored seconds/fractions/positions, the
|
||||
// double-click reset, and the ms time-constant formatter. Split from the editor shell so the
|
||||
// whole domain map is provable without a host; deck_groups owns WHICH controls exist, this owns
|
||||
// what each one's value MEANS.
|
||||
// deck_values.h — the deck's control-id <-> parameter-set BINDING and its snap units: the
|
||||
// normalized 0..1 a knob shows, the write back into the stored seconds/fractions/positions, and
|
||||
// the double-click reset. Split from the editor shell so the whole domain map is provable
|
||||
// without a host; deck_groups owns WHICH controls exist, this owns what each one's value MEANS,
|
||||
// and param/param_format owns how it READS.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
#include "core/instrument/engine/time_stretch.h" // kStretchRateMin/Max (Rate's own range)
|
||||
#include "core/instrument/map/play_seconds.h" // PlaySeconds (the deck's edit target)
|
||||
#include "core/instrument/ui/deck_groups.h" // DeckParam
|
||||
#include "core/instrument/ui/envelope_overlay.h" // kGateStageMaxSeconds
|
||||
#include "core/instrument/ui/param_taper.h" // UnitCategory + the shared tapers
|
||||
#include "core/util/clamp01.h"
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
@@ -30,6 +29,13 @@ inline constexpr double kPitchDepthMaxSemis = kVelocityPitchRangeSemitones;
|
||||
// Key-track knob ceiling (0..200%), shared by the pitch and filter key-track controls.
|
||||
inline constexpr double kKeyTrackMax = 2.0;
|
||||
|
||||
// The key-track norm <-> stored pair, shared by BOTH key-track controls. It gets its own home
|
||||
// because the pitch one's value lives beside the play bundle (on InstrumentParams / SampleData)
|
||||
// and so cannot ride the PlaySeconds binding below — leaving the editor knob, the host's write
|
||||
// path, the live fold and the snap to each spell the division out. Every one of them calls these.
|
||||
inline double keyTrackFromNorm(double norm) { return util::clamp01(norm) * kKeyTrackMax; }
|
||||
inline double keyTrackNormFrom(double keyTrack) { return util::clamp01(keyTrack / kKeyTrackMax); }
|
||||
|
||||
// Rate's range: ALIASES of the stretcher's own measured ratio bounds, so the knob's ends are the
|
||||
// engine's clamp rather than a second opinion of it. The taper takes them as arguments for the
|
||||
// same reason the depth taper takes its throw — engine/time_stretch.h owns the numbers.
|
||||
@@ -43,6 +49,12 @@ inline constexpr double kRateMaxRatio = engine::kStretchRateMax;
|
||||
// shell reads those from the processor.
|
||||
double deckParamNorm(DeckParam id, const PlaySeconds& play);
|
||||
|
||||
// The STORED value a knob's normalized position maps to — the norm -> value half of the binding
|
||||
// on its own, because the audio thread needs it without a PlaySeconds to write into
|
||||
// (`param/param_live`). setDeckParam IS this composed with the field lookup below, so the two
|
||||
// cannot carry different tapers. Answers `norm` unchanged for a control with no stored scalar.
|
||||
double storedFromNorm(DeckParam id, double norm);
|
||||
|
||||
// Applies a committed interaction: a knob's normalized `value`, or a toggle's `segment` (0/1).
|
||||
// Mutates `play` in place, touching exactly the one field the control names.
|
||||
void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment);
|
||||
@@ -75,9 +87,4 @@ UnitCategory deckParamUnit(DeckParam id);
|
||||
// whole displayed percent and therefore take different norm steps.
|
||||
double snapDeckParamNorm(DeckParam id, double norm);
|
||||
|
||||
// A time constant as MILLISECONDS, e.g. "12 ms". Never switches to seconds: the editor reads in
|
||||
// one unit so two stage times are comparable at a glance. Sub-10 ms keeps one decimal so a short
|
||||
// attack is not rounded to a bare "0 ms". Writes at most `len` bytes including the terminator.
|
||||
void formatEnvTimeMs(double seconds, char* buf, std::size_t len);
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
|
||||
@@ -46,11 +46,20 @@ inline double clampCurve(double exponent) {
|
||||
// and a dial swept through the centre cannot skip over it.
|
||||
inline constexpr double kCurveKnobDetent = 0.01;
|
||||
|
||||
// The same travel with the detent NOT applied — the map for a writer that has no drag grid. A
|
||||
// host automation lane delivers a NUMBER, not a gesture, so snapping it would not make the
|
||||
// identity reachable (t == 0.5 already evaluates exp(0) == 1.0 exactly here); it would only
|
||||
// destroy a near-neutral exponent the user set some other way.
|
||||
inline double curveFromKnobNormUndetented(double norm) {
|
||||
const double t = norm < 0.0 ? 0.0 : (norm > 1.0 ? 1.0 : norm);
|
||||
return clampCurve(std::exp((2.0 * t - 1.0) * std::log(kCurveMax)));
|
||||
}
|
||||
|
||||
inline double curveFromKnobNorm(double norm) {
|
||||
const double t = norm < 0.0 ? 0.0 : (norm > 1.0 ? 1.0 : norm);
|
||||
const double off = t - 0.5;
|
||||
if (off < kCurveKnobDetent && off > -kCurveKnobDetent) return kCurveNeutral;
|
||||
return clampCurve(std::exp((2.0 * t - 1.0) * std::log(kCurveMax)));
|
||||
return curveFromKnobNormUndetented(t);
|
||||
}
|
||||
|
||||
inline double knobNormFromCurve(double exponent) {
|
||||
|
||||
@@ -84,6 +84,113 @@ 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
|
||||
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.
|
||||
- **BOTH delivery channels are serviced, and the audio-side one is the normative one.**
|
||||
`IEditController::setParamNormalized` is the CONTROLLER channel — the SDK says a controller
|
||||
"should update the according GUI element(s) only" there, so nothing about the audio may depend
|
||||
on a host calling it. `ProcessData::inputParameterChanges` is the AUDIO channel, and the SDK's
|
||||
own single-component sample (`public.sdk/samples/vst/again/source/againsimple.cpp`) drains it
|
||||
in `process()` while also implementing `setParamNormalized`. We do both, for the same reason.
|
||||
- **The audio thread is the sole writer of the block the ENGINE reads.** Two `LiveParams`
|
||||
blocks: the model's publishers (editor commits, reload, `setState`) write `liveParams_` off
|
||||
the audio thread and may allocate on the way; `process()` merges that block with the host's
|
||||
automation points into `automationLive_`, which is what `SampleData::live` points at. Two
|
||||
blocks rather than one because the seqlock's single-writer contract is load-bearing and the two
|
||||
writers genuinely differ in thread.
|
||||
|
||||
### THE AUTHORITY MODEL — who may write a parameter's value, and until when
|
||||
|
||||
Two passes got this subtly wrong in opposite directions (the first delivered automation on the
|
||||
wrong channel; the second made a point's authority permanent), because the model was in nobody's
|
||||
head and nowhere in the tree. It is here, and the code follows it.
|
||||
|
||||
**`ReaSamplerProcessor::params_` — plus the two instance scalars beside it — is THE model, and
|
||||
the single authority.** Everything else that holds these values is a cache or a courier:
|
||||
|
||||
| Writer | Authority begins | Authority ends |
|
||||
|---|---|---|
|
||||
| Editor gesture (`commitLive` / `commitAndReload`) | mouse-down | the commit lands in the model |
|
||||
| Host controller write (`setParamNormalized`) | the call | the call returns (it writes the model) |
|
||||
| State restore (`setState`) | the call | the call returns |
|
||||
| Bake reset (`adoptBakedCapture`) | the call | the call returns |
|
||||
| Limiter toggle (`setLimiterEnabled`) | the call | the call returns (it writes the model too, but through neither `commitLive` nor `commitAndReload`) |
|
||||
| Reload seed (`reloadInstrument`) | never — it does not write `params_` | it only republishes a live block folded from whatever the model already holds |
|
||||
| **Host automation point** (`IParameterChanges`) | the block it lands in | **the UI thread has folded it into the model and republished** |
|
||||
|
||||
Every writer above except the last two writes the model directly, so for those "authority ends"
|
||||
is just "the write happened". Reload seed is not itself a model write — `reloadInstrument` never
|
||||
touches `params_`; the only write in the tree is `setInstrumentParams`'s, `processor_state.cpp:193`
|
||||
— which is why its row states no authority window of its own. The automation lane is the only one
|
||||
that cannot write directly: the SDK delivers it on the audio thread, where the model path
|
||||
allocates (`resolvePlay` copies velocity curves and spline contours). So it patches the
|
||||
engine-facing block in place and is couriered to the UI thread, which folds it into the model on
|
||||
the next tick.
|
||||
|
||||
**The hold is the bridge across that gap, and nothing more.** Between the point landing and the
|
||||
fold — at most one UI tick — the model does not yet carry the value, so a model republish in that
|
||||
window (any knob move) would revert the automated parameter until the lane's next point. The hold
|
||||
re-applies the point over every merge to stop that. The instant the model carries the value, the
|
||||
hold has no job and is **released**; from then on every writer above reaches the audio normally.
|
||||
|
||||
**Contention resolves BY RULE, not by timing.** A point outranks the model while the lane is
|
||||
driving and the model has not caught up — which is VST3's own authority rule (a lane in
|
||||
read/write mode outranks a plug-in-side set). It does NOT outrank a later restore, bake reset or
|
||||
knob move, because by then the lane is no longer driving that value; the model is.
|
||||
|
||||
**Where it is enforced, and what fails if it stops holding.**
|
||||
- The decision is the pure `core/instrument/param/param_merge`'s `mergeAutomation`;
|
||||
`tests/test_param_merge.cpp`'s
|
||||
`testAHeldPointOutranksTheModelOnlyUntilTheModelCarriesIt` is the test — it asserts both halves,
|
||||
including that a writer AFTER the release reaches the audio. A latch with no release fails it.
|
||||
- The mechanism — the per-slot sequence the audio thread stamps and the UI thread answers, and
|
||||
the acquire/release ordering that makes a release imply the publish is visible — is
|
||||
`automation_channel.h`'s, at its two methods.
|
||||
- **The release is stored LAST in `drainAutomationToModel`**, after `setInstrumentParams` and
|
||||
`publishLiveParams`. Moving it earlier reintroduces a one-block revert.
|
||||
- **`setState` therefore needs no ordering guarantee against the host's first parameter block.**
|
||||
A lane that is driving re-applies over the restore; a lane that merely sent a point once, and
|
||||
had it folded, does not — which is the correct reading of the SDK rule, and the one the second
|
||||
pass got wrong.
|
||||
|
||||
**The editor's `params_` is a CACHE of the model, authoritative for one gesture only.** A commit
|
||||
writes the WHOLE set back, and `notifyParamsFromModel` diffs it — so a stale copy would
|
||||
`performEdit` superseded values the user never touched, which a lane in latch or write mode
|
||||
records. The sync tick re-seeds it (past the drag guard) whenever `paramsGeneration_` has moved
|
||||
under it: the automation fold, the host's generic panel, a state restore.
|
||||
|
||||
**Two independent gates keep a value-identical point off the per-voice fan-out**, and they cover
|
||||
different windows: `AutomationChannel::land` drops a repeat of a standing hold whole (the flat
|
||||
read-mode segment, where a host sends one point per block), and the merge publishes only when the
|
||||
merged block differs from the last (a model republish that changed nothing). Neither is measured
|
||||
against a performance budget — they are there because `VoiceEngine::applyLiveToActive` runs
|
||||
`voice.applyLive` over every active voice, and neither case needs it.
|
||||
|
||||
- **The automation values fold back into the model on the UI thread** (`drainAutomationToModel`,
|
||||
called from `getState`, the editor's sync tick, and `instrument_bake.cpp:125` — at the HEAD of
|
||||
the bake chain, before the render, not its reload tail). The blob is authoritative, so a value
|
||||
that never came back would be lost on save. The fold is suppressed from notifying the host —
|
||||
the values came FROM it, and echoing them would let a lane in write mode re-record its own
|
||||
playback.
|
||||
- **`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
|
||||
`[verify — DAW]` REAPER's own per-parameter MIDI learn is expected to cover the case without
|
||||
freezing anything. `IParameterFunctionName` and `IAutomationState` are assessed and not
|
||||
implemented — `bake/CLAUDE.md` owns the `IAutomationState` reasoning, at its one consequence
|
||||
site.
|
||||
|
||||
**Non-goals / guardrails.**
|
||||
- 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
|
||||
@@ -112,6 +219,9 @@ 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).
|
||||
- `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_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, the audio thread's queue drain and the UI thread's fold + release, and the `beginEdit`/`performEdit`/`endEdit` notification path every internal writer reaches through `setInstrumentParams`. Decides nothing — the pure module owns the table, the laws, the formatter and the merge.
|
||||
- `automation_channel.h` — the host automation lane's per-instance state and the mechanism of its authority lifetime: the audio thread's hold, the per-slot sequence it stamps, the UI thread's release answer, and the acquire/release ordering that makes a release imply the model publish is visible. The MODEL it enforces is the Authority section above; the pure decision it feeds is `core/instrument/param/param_merge`. Internal to this TU family.
|
||||
- `processor_snapshot.h` — the two namespace-scope aggregates the processor hands across its thread boundary: `LoadedInstrument` (the decoded capture plus the engine playing it, swapped through the drain slot) and `MasterBusMeter` (what the audio thread publishes per block for the editor's meter). Split out of `reasampler_processor.h` on `editor_interaction.h`'s grounds — neither is behaviour.
|
||||
- `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_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.
|
||||
@@ -119,6 +229,14 @@ declared ahead of the instrument slots at that member in `reasampler_processor.h
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **`reasampler_processor.h` no longer needs a ceiling exception, and the one it had rested on a
|
||||
false premise.** It was described as ONE class declaration; it also carried two namespace-scope
|
||||
aggregates (`MasterBusMeter`, `LoadedInstrument`) and the automation lane's own state. Both are
|
||||
now split out — `processor_snapshot.h` and `automation_channel.h`, on the same grounds
|
||||
`editor_interaction.h` was split out of `reasampler_editor.h` in this directory: neither is
|
||||
behaviour. What remains is under the ceiling. Its bulk is the drain-slot proof and the
|
||||
RT-discipline constraints, which the comment conventions name as keep-worthy.
|
||||
|
||||
- **The bake click only ARMS; the editor's sync tick runs it.** Calling
|
||||
`Main_OnCommandEx` inline from `WM_LBUTTONDOWN` would run the extension's whole landing
|
||||
nested inside a mouse handler with `SetCapture` held, while the invoked action re-points
|
||||
|
||||
@@ -46,6 +46,7 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp")
|
||||
reasampler_processor.cpp
|
||||
processor_state.cpp
|
||||
processor_reload.cpp
|
||||
instrument_params.cpp
|
||||
# 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
|
||||
# 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
|
||||
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
|
||||
param_id param_units param_format param_live param_merge
|
||||
limiter meter_accumulate meter_ballistics master_meter bake_hold
|
||||
file_bytes curve_law stroke_aa
|
||||
curve_tessellate
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
// automation_channel.h — the host automation lane's per-instance state, and the ONE place its
|
||||
// AUTHORITY LIFETIME is mechanised: a point outranks the model from the block it lands in until
|
||||
// the UI thread has folded it back into the model AND republished. This directory's CLAUDE.md
|
||||
// states the model; `core/instrument/param/param_merge` is the pure decision this feeds.
|
||||
// The release itself is observed the NEXT BLOCK, not the instant it happens — see
|
||||
// refreshReleases().
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
#include <cassert>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
#include "core/instrument/param/param_merge.h"
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
class ReaSamplerProcessor; // the one class that legitimately mints a ReleaseProof
|
||||
|
||||
// The DeckParam ordinal space — what the automation slots and the notification diff both index.
|
||||
inline constexpr std::size_t kDeckParamSlots = instrument::param::kDeckParamSlots;
|
||||
|
||||
class AutomationChannel {
|
||||
public:
|
||||
// Evidence release() below requires: the model has actually caught up with the
|
||||
// point being released. The two factories are named for the case they cover —
|
||||
// `fromPublish` for an observed model republish, `noRepublishNeeded` for the two
|
||||
// cases drainAutomationToModel finds nothing to publish (the fold left the model
|
||||
// unchanged, or no engine exists yet to read the live block). This is NOT a full
|
||||
// compile-time proof of ordering: `noRepublishNeeded` still lets `ReaSamplerProcessor`
|
||||
// claim the exemption with no publish at all. What it enforces structurally is that a
|
||||
// caller cannot spell release()'s argument without naming, by which factory it called,
|
||||
// WHICH exemption it is claiming — greppable by a reviewer, not silently inline — and
|
||||
// both factories are restricted to the one class that legitimately needs either.
|
||||
// The private, user-provided default constructor plus the deleted copy constructor
|
||||
// close the two ways `{}` and copy-reuse would otherwise fabricate one for free; see
|
||||
// the two definitions below for which C++17 rule each closes.
|
||||
class ReleaseProof {
|
||||
private:
|
||||
friend class ReaSamplerProcessor;
|
||||
|
||||
static ReleaseProof fromPublish(std::uint32_t before, std::uint32_t after) {
|
||||
// publish() advances the generation BY CONSTRUCTION (gen + 2, skipping 0 on wrap —
|
||||
// live_params.h), so `after == before` is unreachable from this, its one call site,
|
||||
// short of ~2^31 publishes wrapping exactly onto `before`. It also cannot see the
|
||||
// actually-reachable failure mode, a caller falsely claiming noRepublishNeeded() —
|
||||
// that path never runs this function. DEBUG-ONLY on purpose: an unconditional abort
|
||||
// here would take down a musician's whole REAPER session, unsaved work on every other
|
||||
// track and plugin included, for a condition this call site cannot produce; a debug
|
||||
// build (where the test suite runs) is where a future regression in publish()'s
|
||||
// advance guarantee should be caught.
|
||||
assert(after != before && "publish() must advance the generation");
|
||||
return ReleaseProof{};
|
||||
}
|
||||
static ReleaseProof noRepublishNeeded() { return ReleaseProof{}; }
|
||||
|
||||
// User-PROVIDED (a body, not `= default`): under C++17 a class with no data
|
||||
// members and only a user-DECLARED (not user-provided) default constructor is
|
||||
// still an aggregate, because C++17's aggregate rule excludes private data
|
||||
// members only, not private constructors — so `ReleaseProof{}` would perform
|
||||
// aggregate init and never call this. A user-provided constructor defeats that.
|
||||
// (C++20's P1008 closes the same hole at the language level; this project is
|
||||
// pinned to C++17 — CMakeLists.txt:28 — so the class must close it itself.)
|
||||
ReleaseProof() {}
|
||||
// Closes the other free-mint path: the implicit copy constructor is public by
|
||||
// default, so one legitimately-minted proof cached in a member could be replayed
|
||||
// by any later caller with no new publish behind it. A user-declared copy
|
||||
// constructor (deleted or not) also suppresses the implicit move constructor, so
|
||||
// no move-based replay path opens in its place — `release()` below takes this by
|
||||
// const reference for exactly that reason, rather than needing one back.
|
||||
ReleaseProof(const ReleaseProof&) = delete;
|
||||
};
|
||||
|
||||
// --- AUDIO THREAD -------------------------------------------------------------------
|
||||
// A point landed for `slot`. `ridesTheBlock` is false for a control that reaches the audio
|
||||
// beside the live block (master gain, whose route is the processor's own atomic): such a
|
||||
// point takes no hold and does not make the block dirty, so a lane on it alone cannot drive
|
||||
// the per-voice fan-out every block for a value the block does not carry.
|
||||
//
|
||||
// Answers whether the block MOVED, which is what makes the merge conditional. A repeat of a
|
||||
// standing hold moves nothing and is dropped whole — no hold rewrite, no UI publish — because
|
||||
// it would only make the fold rewrite the model with the value already in it.
|
||||
bool land(std::size_t slot, double normalized, bool ridesTheBlock) {
|
||||
if (ridesTheBlock && !instrument::param::automationPointMoves(slots_[slot], normalized)) {
|
||||
return false;
|
||||
}
|
||||
if (ridesTheBlock) {
|
||||
slots_[slot].norm = normalized;
|
||||
slots_[slot].held = true;
|
||||
}
|
||||
published_[slot].store(normalized, std::memory_order_relaxed);
|
||||
// The sequence is stored LAST and with release: the fold reads it FIRST and only then
|
||||
// trusts the value beside it.
|
||||
seq_[slot].store(seq_[slot].load(std::memory_order_relaxed) + 1,
|
||||
std::memory_order_release);
|
||||
any_.store(true, std::memory_order_release);
|
||||
return ridesTheBlock;
|
||||
}
|
||||
|
||||
// Refreshes each held slot's release answer. Runs only inside process()'s merge branch, so a
|
||||
// release lands the NEXT BLOCK after the UI thread makes it, never the same instant — benign
|
||||
// on the `moved` path because publishLiveParams always bumps the generation that branch
|
||||
// checks, so the next block is guaranteed to run this. The `noRepublishNeeded()` path has no
|
||||
// publish and no generation bump to guarantee that — land() drops a static lane's repeats, so
|
||||
// `moved` is false there too — but it is equally benign: the model already carries the value
|
||||
// (that is why nothing published), so the release is simply observed whenever the generation
|
||||
// next moves under ANY writer, not specifically this one. Must run BEFORE the model block is
|
||||
// read: the acquire here synchronizes with the UI thread's release store, which it makes only
|
||||
// AFTER republishing the model — so a slot seen released is one whose value any block read
|
||||
// after this point is guaranteed to already carry.
|
||||
void refreshReleases() {
|
||||
for (std::size_t i = 0; i < kDeckParamSlots; ++i) {
|
||||
if (!slots_[i].held) continue;
|
||||
slots_[i].folded = folded_[i].load(std::memory_order_acquire) ==
|
||||
seq_[i].load(std::memory_order_relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
instrument::param::AutomationSlot* slots() { return slots_; }
|
||||
|
||||
// --- UI THREAD ----------------------------------------------------------------------
|
||||
// True when at least one point has landed since the last drain.
|
||||
bool takePending() { return any_.exchange(false, std::memory_order_acquire); }
|
||||
|
||||
// The value and sequence of `slot`'s unfolded point, or false when there is nothing new.
|
||||
bool takeSlot(std::size_t slot, double& value, std::uint32_t& seq) const {
|
||||
seq = seq_[slot].load(std::memory_order_acquire);
|
||||
if (seq == folded_[slot].load(std::memory_order_relaxed)) return false;
|
||||
value = published_[slot].load(std::memory_order_relaxed);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Releases `slot`'s hold. The `ReleaseProof` argument is the enforcement: it can only be
|
||||
// constructed once the model carrying this point has been republished (or shown not to need
|
||||
// it), so a caller earlier in that ordering has no value to pass. By const reference, not
|
||||
// value: the copy constructor is deleted (see ReleaseProof), and the one caller releasing a
|
||||
// whole fold's worth of slots passes the same proof through this repeatedly.
|
||||
void release(std::size_t slot, std::uint32_t seq, const ReleaseProof&) {
|
||||
folded_[slot].store(seq, std::memory_order_release);
|
||||
}
|
||||
|
||||
private:
|
||||
instrument::param::AutomationSlot slots_[kDeckParamSlots] = {}; // audio thread only
|
||||
std::atomic<double> published_[kDeckParamSlots] = {};
|
||||
std::atomic<std::uint32_t> seq_[kDeckParamSlots] = {}; // written by the audio thread
|
||||
std::atomic<std::uint32_t> folded_[kDeckParamSlots] = {}; // written by the UI thread
|
||||
std::atomic<bool> any_{false}; // makes the UI thread's idle drain a single exchange
|
||||
};
|
||||
|
||||
// The publication atomics above are read on the audio thread; a locked implementation would be a
|
||||
// hidden mutex on it. Structural rather than assumed, for a class whose thesis is RT discipline.
|
||||
static_assert(std::atomic<double>::is_always_lock_free,
|
||||
"the automation publication must be lock-free — the audio thread writes it");
|
||||
static_assert(std::atomic<std::uint32_t>::is_always_lock_free,
|
||||
"the automation sequence must be lock-free — the audio thread writes it");
|
||||
|
||||
} // namespace reasampler::vst
|
||||
@@ -1,23 +1,25 @@
|
||||
// editor_controls.cpp — the ReaSamplerEditor's parameter plumbing: the band-stack layout
|
||||
// resolve every paint/hit-test path shares, the shell's half of the control-value binding (the
|
||||
// per-instance controls the parameter set does not carry — key-track, voice count, master gain,
|
||||
// preview velocity — plus the value labels), and the node-drag clamp bounds. The parameter-set
|
||||
// half is the pure `deck_values` module. The orthogonal half — which stored struct each editor
|
||||
// selection names — is editor_models. Value logic only: no painting, no window plumbing.
|
||||
// preview velocity — plus each knob's plain value and its label), and the node-drag clamp
|
||||
// bounds. The parameter-set half is the pure `deck_values` module. The orthogonal half — which
|
||||
// stored struct each editor selection names — is editor_models. Value logic only.
|
||||
|
||||
#include "shell/instrument/reasampler_editor.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath> // isfinite (the gain's -inf label)
|
||||
#include <cstdint>
|
||||
#include <cstdio> // snprintf (deck value labels)
|
||||
#include <string>
|
||||
#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/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/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/util/clamp01.h"
|
||||
#include "core/util/curve_law.h" // the ONE curve-exponent domain
|
||||
@@ -33,18 +35,12 @@ using instrument::ui::deckHeight;
|
||||
using instrument::ui::kDeckKnobSize;
|
||||
using instrument::ui::kPad;
|
||||
using instrument::ui::deckParamNorm;
|
||||
using instrument::ui::formatEnvTimeMs;
|
||||
using instrument::ui::kEnvTimeMaxSeconds;
|
||||
using instrument::ui::kKeyTrackMax;
|
||||
using instrument::ui::resetDeckParam;
|
||||
using instrument::ui::sampleDeckGroups;
|
||||
using instrument::ui::setDeckParam;
|
||||
using instrument::engine::formatMasterGainLabel;
|
||||
using instrument::engine::masterGainLinearFromNorm;
|
||||
using instrument::engine::masterGainNormFromLinear;
|
||||
using instrument::engine::filter::filterCutoffHzFromNorm;
|
||||
using instrument::engine::filter::filterDriveDepthFromNorm;
|
||||
using instrument::engine::filter::filterQFromNorm;
|
||||
using util::clamp01;
|
||||
|
||||
namespace {
|
||||
@@ -122,7 +118,7 @@ double ReaSamplerEditor::deckControlNorm(int id) const {
|
||||
if (id == kBakeHoldKnobId) return bakeHoldNorm();
|
||||
switch (static_cast<ParamControl>(id)) {
|
||||
case ParamControl::kKeyTrack:
|
||||
return clamp01(params_.keyTrack / kKeyTrackMax);
|
||||
return instrument::ui::keyTrackNormFrom(params_.keyTrack);
|
||||
case ParamControl::kVoiceCount:
|
||||
return clamp01(static_cast<double>(voiceCount_ - kMinVoiceCount) /
|
||||
static_cast<double>(kMaxVoiceCount - kMinVoiceCount));
|
||||
@@ -220,117 +216,45 @@ void ReaSamplerEditor::applyDeckKnob(int id, double norm) {
|
||||
}
|
||||
}
|
||||
|
||||
std::string ReaSamplerEditor::deckValueLabel(int id) const {
|
||||
char buf[24];
|
||||
buf[0] = '\0';
|
||||
const PlaySeconds& play = params_.play;
|
||||
switch (id < 0 ? ParamControl::kCount : static_cast<ParamControl>(id)) {
|
||||
case ParamControl::kAttack:
|
||||
formatEnvTimeMs(play.adsr.attackSeconds, buf, sizeof(buf)); break;
|
||||
case ParamControl::kHold:
|
||||
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;
|
||||
double ReaSamplerEditor::deckPlainValue(int id) const {
|
||||
const auto deck = static_cast<instrument::ui::DeckParam>(id);
|
||||
// A curve exponent is read off its stored field, never round-tripped through the knob law:
|
||||
// that law's centre detent snaps anything near-neutral back to exactly 1.0, so a round trip
|
||||
// would misreport a stored exponent that isn't neutral as 1.00. The host has only the norm
|
||||
// and therefore cannot make this distinction — param/CLAUDE.md records the divergence.
|
||||
if (instrument::ui::deckParamUnit(deck) == instrument::ui::UnitCategory::Exponent) {
|
||||
return curveExponentFor(id, params_.play);
|
||||
}
|
||||
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.
|
||||
const double plain = deckPlainValue(id);
|
||||
char digits[24];
|
||||
instrument::param::formatPlainFor(deck, plain, digits, sizeof(digits));
|
||||
const auto kind = instrument::param::unitKindFor(deck);
|
||||
const char* caret =
|
||||
instrument::ui::deckParamUnit(deck) == instrument::ui::UnitCategory::Exponent ? "^" : "";
|
||||
// The gain at true silence reads "-inf", not "-infdB": there is no decibel value there.
|
||||
if (kind == instrument::param::UnitKind::Decibels && !std::isfinite(plain)) {
|
||||
return std::string(digits);
|
||||
}
|
||||
// The stage times are the one category that carries a space before its unit, and always did —
|
||||
// this surface's own typography, not the host's (ParameterInfo::units is the bare string).
|
||||
const char* gap = kind == instrument::param::UnitKind::Time ? " " : "";
|
||||
return caret + std::string(digits) + gap + instrument::param::unitStringFor(deck);
|
||||
}
|
||||
|
||||
EnvClampBounds ReaSamplerEditor::envClampBounds() const {
|
||||
@@ -347,8 +271,9 @@ EnvClampBounds ReaSamplerEditor::envClampBounds() const {
|
||||
|
||||
void ReaSamplerEditor::applyParamControl(int id, double value, int segment) {
|
||||
if (id == static_cast<int>(ParamControl::kKeyTrack)) {
|
||||
// keyTrack sits beside the play bundle (0..200% over kKeyTrackMax); the knob maps 0..1.
|
||||
params_.keyTrack = clamp01(value) * kKeyTrackMax;
|
||||
// keyTrack sits beside the play bundle, so it takes deck_values' own pair rather than
|
||||
// the PlaySeconds binding.
|
||||
params_.keyTrack = instrument::ui::keyTrackFromNorm(value);
|
||||
} else {
|
||||
applyControl(id, params_.play, value, segment);
|
||||
}
|
||||
|
||||
@@ -85,6 +85,13 @@ void ReaSamplerEditor::onMouseUp(int x, int y) {
|
||||
invalidate();
|
||||
}
|
||||
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 int paramId = dragParamId_;
|
||||
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
|
||||
// rebuild (setVoiceCount) fires ONCE here on release — not per integer step.
|
||||
const bool deckTransient =
|
||||
kind == DragKind::kDeckKnob &&
|
||||
(paramId == -2 || paramId == static_cast<int>(ParamControl::kVoiceCount) ||
|
||||
paramId == static_cast<int>(ParamControl::kMasterGain));
|
||||
kind == DragKind::kDeckKnob && deckKnobIsProcessorSide(paramId);
|
||||
if (kind == DragKind::kScrollThumb || deckTransient) {
|
||||
// Commit the voice count now that the drag is complete (one rebuild per full drag).
|
||||
if (deckTransient && processor_ &&
|
||||
|
||||
@@ -125,6 +125,12 @@ bool ReaSamplerEditor::mouseDownDeck(const FaceLayout& fl, int x, int y) {
|
||||
dragStartParams_ = params_;
|
||||
dragStartX_ = x;
|
||||
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();
|
||||
}
|
||||
// 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);
|
||||
// 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.
|
||||
if (dragCommitsLive(DragKind::kDeckKnob, dragParamId_)) commitLive();
|
||||
// point: the note already sounding tracks the hand on the knob. The processor-side knobs
|
||||
// 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();
|
||||
}
|
||||
|
||||
|
||||
@@ -119,6 +119,10 @@ bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) {
|
||||
dragStartEnv_ = env;
|
||||
dragSampleFrames_ = frames;
|
||||
dragStartParams_ = params_;
|
||||
// Peer of mouseDownDeck's bracket. LATCHING with no id named, because a node or
|
||||
// knot drag can move more than one exposed parameter and the grab cannot know
|
||||
// which; the release and capture-lost paths close it generically.
|
||||
if (processor_) processor_->beginParamGestureLatch();
|
||||
return true; // node moves once the cursor drags
|
||||
}
|
||||
return splineOverlayClick(overlay, x, y, gesture, /*addOnEmptySpace=*/false);
|
||||
|
||||
@@ -120,6 +120,11 @@ void ReaSamplerEditor::attachedToParent() {
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::removedFromParent() {
|
||||
// The processor outlives this view, so a bracket left open here would leave the host holding
|
||||
// an edit forever and every later internal write to that parameter would emit a bare
|
||||
// performEdit. The capture-lost path normally closes it; this does not rely on Windows
|
||||
// delivering WM_CAPTURECHANGED before the window goes away.
|
||||
if (processor_) processor_->endParamGesture();
|
||||
if (childHwnd_) {
|
||||
KillTimer(childHwnd_, kSyncTimerId); // stop the poll before the window goes away
|
||||
DestroyWindow(childHwnd_);
|
||||
@@ -249,9 +254,7 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
|
||||
// abandoned value indefinitely instead of rolling back.
|
||||
const bool transient = self->drag_ == DragKind::kScrollThumb ||
|
||||
(self->drag_ == DragKind::kDeckKnob &&
|
||||
(self->dragParamId_ == -2 ||
|
||||
self->dragParamId_ == static_cast<int>(ParamControl::kVoiceCount) ||
|
||||
self->dragParamId_ == static_cast<int>(ParamControl::kMasterGain)));
|
||||
ReaSamplerEditor::deckKnobIsProcessorSide(self->dragParamId_));
|
||||
if (!transient) {
|
||||
self->params_ = self->dragStartParams_;
|
||||
// A live drag already reached the voices AND the processor's own
|
||||
@@ -263,6 +266,10 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
|
||||
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->dragParamId_ = -1;
|
||||
self->dragInnerCellId_ = -1; // inner-dial drag state (peer reset)
|
||||
|
||||
@@ -70,7 +70,7 @@ void ReaSamplerEditor::refreshFromBank() {
|
||||
samples_ = banksJson ? listSamples(*banksJson) : std::vector<SampleChoice>{};
|
||||
banks_ = banksJson ? listBanks(*banksJson) : std::vector<BankChoice>{};
|
||||
selectedId_ = processor_->selectedSampleId();
|
||||
params_ = processor_->instrumentParams();
|
||||
params_ = processor_->instrumentParams(seenParamsGeneration_);
|
||||
channelMode_ = processor_->channelMode();
|
||||
voiceCount_ = processor_->voiceCount();
|
||||
voiceMode_ = processor_->voiceMode();
|
||||
@@ -141,6 +141,21 @@ void ReaSamplerEditor::onSyncTimer() {
|
||||
// edit surface exactly as a reload would. Unconditional: it self-cancels when nothing is
|
||||
// armed, so no commit site has to remember to ask for it.
|
||||
processor_->flushLatencyRestart();
|
||||
// Peers of it: both deliver work the originating thread could not do where it stood — a host
|
||||
// callback from inside reloadMutex_, and a model write from the audio thread.
|
||||
processor_->flushGainNotify();
|
||||
processor_->drainAutomationToModel();
|
||||
|
||||
// params_ is a CACHE of the processor's model, authoritative only for the duration of a
|
||||
// gesture — which is why this sits past the drag guard. Re-seed it whenever the model has
|
||||
// moved under it: the automation fold just above, the host's own generic panel, a state
|
||||
// restore. Without this, commitLive writes the WHOLE stale set back and notifyParamsFromModel
|
||||
// diffs it as a real edit, performEdit-ing superseded values the user never touched — which a
|
||||
// lane in latch or write mode records.
|
||||
if (processor_->instrumentParamsGeneration() != seenParamsGeneration_) {
|
||||
params_ = processor_->instrumentParams(seenParamsGeneration_);
|
||||
invalidate();
|
||||
}
|
||||
|
||||
// Resolve the bake affordance's availability on the SAME tick that paints it, so it
|
||||
// can never be enabled on one tick and refuse on the next.
|
||||
@@ -196,7 +211,11 @@ void ReaSamplerEditor::commitAndReload() {
|
||||
// self-contained for that sample.
|
||||
if (!processor_) return;
|
||||
processor_->setSelectedSampleId(selectedId_);
|
||||
processor_->setInstrumentParams(params_);
|
||||
// This copy IS the model now, so adopt the generation IT produced (the return, not a second
|
||||
// separate query — a write landing between the two would make this adopt a generation newer
|
||||
// than the copy just sent) rather than re-seeding off it on the next tick. Same reason at
|
||||
// commitLive.
|
||||
seenParamsGeneration_ = processor_->setInstrumentParams(params_);
|
||||
processor_->reloadInstrument();
|
||||
// The reload may have auto-defaulted the channel mode (implicit only) — re-read so the
|
||||
// toggle draws what the engine actually decoded with.
|
||||
@@ -209,10 +228,16 @@ void ReaSamplerEditor::commitAndReload() {
|
||||
void ReaSamplerEditor::commitLive() {
|
||||
// UI thread only. See the declaration for why this still writes the parameter set.
|
||||
if (!processor_) return;
|
||||
processor_->setInstrumentParams(params_);
|
||||
seenParamsGeneration_ = processor_->setInstrumentParams(params_);
|
||||
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 {
|
||||
// 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
|
||||
|
||||
@@ -120,6 +120,9 @@ BakeChainResult runBake(ReaSamplerProcessor& processor) {
|
||||
const std::optional<Tempo> tempo = Tempo::fromBpm(bridge.projectTempoBpm());
|
||||
if (!tempo) return fail("the project tempo could not be read");
|
||||
|
||||
// Fold anything the host's automation wrote into the model FIRST: the bake renders the sound
|
||||
// the user approved, and an automated value the model has not picked up yet is part of it.
|
||||
processor.drainAutomationToModel();
|
||||
const InstrumentParams dialed = processor.instrumentParams();
|
||||
const int rootNote = dialed.rootOverride ? *dialed.rootOverride : source->rootNote;
|
||||
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
// instrument_params.cpp — the VST3 adapter over core/instrument/param: the Parameter subclass
|
||||
// whose toPlain/toNormalized ARE the taper, the construction of the unit and parameter lists,
|
||||
// the model projection both directions, and both delivery channels (the controller's write and
|
||||
// the audio thread's queue drain). It DECIDES nothing — the pure module owns the frozen table,
|
||||
// the laws and the formatter.
|
||||
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
|
||||
#include "base/source/fstring.h"
|
||||
#include "pluginterfaces/base/ustring.h"
|
||||
#include "pluginterfaces/vst/ivstparameterchanges.h" // IParameterChanges / IParamValueQueue
|
||||
|
||||
#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_live.h" // writeHostParam (the model side of a host write)
|
||||
#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();
|
||||
writeDeckParamToModel(params, row->deck, value);
|
||||
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));
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::writeDeckParamToModel(InstrumentParams& params, DeckParam deck,
|
||||
double normalized) {
|
||||
// WHICH home a control's value has is param::valueHomeFor's answer, not a list repeated here
|
||||
// — the read peer below branches on the same predicate, the editor draws the same split at
|
||||
// applyParamControl, and the instance-scalar set's ARITY is pinned by param_live's own test,
|
||||
// so a third one cannot appear without a failure. The value map itself is param_live's, which
|
||||
// is what makes this write and the audio thread's patch of the same point agree.
|
||||
if (param::valueHomeFor(deck) == param::ValueHome::InstanceScalar) {
|
||||
// Master gain never arrives here — both callers route it to setMasterGainLinear, its own
|
||||
// funnel — so key-track is the whole of this arm.
|
||||
if (deck == DeckParam::kKeyTrack) {
|
||||
params.keyTrack = instrument::ui::keyTrackFromNorm(normalized);
|
||||
}
|
||||
return;
|
||||
}
|
||||
param::writeHostParam(deck, params.play, normalized);
|
||||
}
|
||||
|
||||
double ReaSamplerProcessor::modelParamNormalized(const InstrumentParams& params,
|
||||
DeckParam deck) const {
|
||||
if (param::valueHomeFor(deck) == param::ValueHome::InstanceScalar) {
|
||||
return deck == DeckParam::kMasterGain
|
||||
? instrument::engine::masterGainNormFromLinear(masterGainLinear())
|
||||
: instrument::ui::keyTrackNormFrom(params.keyTrack);
|
||||
}
|
||||
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 double* beforeNorms,
|
||||
const InstrumentParams& after) {
|
||||
if (paramNotifySuppressed_) return;
|
||||
// The bake's reset moves ~40 values at once. Grouping them tells the host they are ONE act,
|
||||
// which is what an undo stack and an automation lane both want; the SDK provides exactly
|
||||
// this for exactly this case (ivsteditcontroller.h, IComponentHandler2).
|
||||
const bool group = componentHandler2 && !gestureLatching_;
|
||||
if (group) componentHandler2->startGroupEdit();
|
||||
for (const param::ParamRow& row : param::exposedParams()) {
|
||||
if (row.deck == DeckParam::kMasterGain) continue; // its own funnel notifies it
|
||||
const double now = modelParamNormalized(after, row.deck);
|
||||
if (now == beforeNorms[static_cast<std::size_t>(row.deck)]) continue;
|
||||
notifyParamChanged(row.id, now);
|
||||
}
|
||||
if (group) componentHandler2->finishGroupEdit();
|
||||
}
|
||||
|
||||
bool ReaSamplerProcessor::gestureIsOpen(param::ParamId id) const {
|
||||
for (std::size_t i = 0; i < openGestureCount_; ++i) {
|
||||
if (openGestureIds_[i] == id) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::notifyParamChanged(param::ParamId id, double normalized) {
|
||||
EditControllerEx1::setParamNormalized(id, normalized);
|
||||
if (!componentHandler) return;
|
||||
if (gestureIsOpen(id)) {
|
||||
performEdit(id, normalized);
|
||||
return;
|
||||
}
|
||||
if (gestureLatching_ && openGestureCount_ < kMaxOpenGestures) {
|
||||
// First move this drag has made on this parameter: open its bracket and hold it, so the
|
||||
// whole drag is one edit rather than a run of one-point ones. An UNRELATED writer that
|
||||
// reaches here mid-drag (the sync tick's flushGainNotify) latches into the same bracket
|
||||
// set, so the host sees its touch end when the drag does rather than at once — bounded by
|
||||
// the drag and correctly closed, and the alternative (a second bracket state per writer)
|
||||
// buys a distinction no host acts on.
|
||||
openGestureIds_[openGestureCount_++] = id;
|
||||
beginEdit(id);
|
||||
performEdit(id, normalized);
|
||||
return;
|
||||
}
|
||||
// Every non-drag writer — a reset, 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.
|
||||
beginEdit(id);
|
||||
performEdit(id, normalized);
|
||||
endEdit(id);
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::beginParamGestureLatch() {
|
||||
endParamGesture(); // a grab while one is open cannot leave the previous unclosed
|
||||
gestureLatching_ = true;
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::beginParamGesture(DeckParam deck) {
|
||||
beginParamGestureLatch();
|
||||
const param::ParamId id = param::paramIdFor(deck);
|
||||
if (id == 0 || !param::isExposed(deck)) return;
|
||||
openGestureIds_[openGestureCount_++] = id;
|
||||
beginEdit(id);
|
||||
}
|
||||
|
||||
bool ReaSamplerProcessor::drainInputParameterChanges(IParameterChanges* changes) {
|
||||
if (!changes) return false;
|
||||
// RT-SAFE, and the one non-obvious part of that: exposedRowFor walks exposedParams(), whose
|
||||
// backing vector is a function-local static built on FIRST CALL. buildParameterList() calls
|
||||
// it from initialize(), which the SDK guarantees precedes any process() — so the allocation
|
||||
// has already happened by the time the audio thread gets here.
|
||||
bool landed = false;
|
||||
const int32 queues = changes->getParameterCount();
|
||||
for (int32 q = 0; q < queues; ++q) {
|
||||
IParamValueQueue* queue = changes->getParameterData(q);
|
||||
if (!queue) continue;
|
||||
const int32 points = queue->getPointCount();
|
||||
if (points <= 0) continue;
|
||||
// The LAST point of the queue wins for the block. Applying every point at its sample
|
||||
// offset would put a "did anything change" question on the per-voice-per-sample path,
|
||||
// which the phase-wide guardrail forbids.
|
||||
int32 offset = 0;
|
||||
ParamValue value = 0.0;
|
||||
if (queue->getPoint(points - 1, offset, value) != kResultTrue) continue;
|
||||
const param::ParamRow* row = param::exposedRowFor(queue->getParameterId());
|
||||
if (!row) continue;
|
||||
// Master gain reaches the audio beside the block rather than through it, so its
|
||||
// automation write is the same one relaxed store the knob makes — and it takes no hold,
|
||||
// which is what keeps a lane on it alone from republishing the block every block.
|
||||
const bool ridesTheBlock = row->deck != DeckParam::kMasterGain;
|
||||
if (!ridesTheBlock) {
|
||||
masterGain_.store(
|
||||
static_cast<float>(instrument::engine::masterGainLinearFromNorm(value)),
|
||||
std::memory_order_relaxed);
|
||||
}
|
||||
// Also published to the UI thread, which folds it back into the model — the blob stays
|
||||
// authoritative, so a value that never came back would be lost on save.
|
||||
landed |= automation_.land(static_cast<std::size_t>(row->deck), value, ridesTheBlock);
|
||||
}
|
||||
return landed;
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::drainAutomationToModel() {
|
||||
if (!automation_.takePending()) return;
|
||||
InstrumentParams params = instrumentParams();
|
||||
// What was folded, and at which sequence. Held back rather than released as we go: the
|
||||
// release below is a statement that the MODEL carries the point, which is only true once the
|
||||
// publish has happened.
|
||||
std::size_t foldedSlots[kDeckParamSlots];
|
||||
std::uint32_t foldedSeqs[kDeckParamSlots];
|
||||
std::size_t foldedCount = 0;
|
||||
bool moved = false;
|
||||
for (const param::ParamRow& row : param::exposedParams()) {
|
||||
const auto slot = static_cast<std::size_t>(row.deck);
|
||||
double value = 0.0;
|
||||
std::uint32_t seq = 0;
|
||||
if (!automation_.takeSlot(slot, value, seq)) continue;
|
||||
foldedSlots[foldedCount] = slot;
|
||||
foldedSeqs[foldedCount] = seq;
|
||||
++foldedCount;
|
||||
// Master gain's model IS the atomic the audio thread already wrote; there is nothing to
|
||||
// fold, only the controller cache to refresh below.
|
||||
if (row.deck == DeckParam::kMasterGain) continue;
|
||||
// A present-but-static lane resends the SAME point every tick; writing it back would
|
||||
// republish liveParams_ and move paramsGeneration_ — a full model copy, a controller
|
||||
// cache write and an editor repaint, every tick, forever, for a value that never moved.
|
||||
// Compared on the STORED side, never the normalized one: toNormalized(toPlain(n)) == n
|
||||
// does NOT hold in general (param_taper.h — "no log map satisfies it in double"), so
|
||||
// comparing `value` to modelParamNormalized(params, ...) skipped almost nothing for the
|
||||
// 33 of 43 exposed controls whose taper is log/curved rather than identity — the churn
|
||||
// this comment describes ran unabated for those. hostStoredFromNorm is the exact map
|
||||
// writeDeckParamToModel below applies, so comparing its answer against the field it would
|
||||
// land in asks "would this write change anything", not "are two norms equal". The
|
||||
// release below still fires either way: the model already carries the point regardless
|
||||
// of whether this write actually runs.
|
||||
const double newStored = param::hostStoredFromNorm(row.deck, value);
|
||||
bool unchanged = false;
|
||||
if (row.deck == DeckParam::kKeyTrack) {
|
||||
unchanged = newStored == params.keyTrack;
|
||||
} else if (float* f = instrument::ui::deckFloatField(row.deck, params.play)) {
|
||||
// The filter's four store a float: compared at that width, since that is what a
|
||||
// re-write would actually round to, not the double newStored computes before it.
|
||||
unchanged = static_cast<float>(newStored) == *f;
|
||||
} else if (double* d = instrument::ui::deckDoubleField(row.deck, params.play)) {
|
||||
unchanged = newStored == *d;
|
||||
}
|
||||
if (unchanged) continue;
|
||||
writeDeckParamToModel(params, row.deck, value);
|
||||
moved = true;
|
||||
}
|
||||
// Suppressed for the whole fold: these values CAME from the host, and echoing them back
|
||||
// through performEdit would let a lane in write mode re-record its own playback. The
|
||||
// controller cache is still refreshed, so the host's display and the editor follow.
|
||||
const bool wasSuppressed = paramNotifySuppressed_;
|
||||
paramNotifySuppressed_ = true;
|
||||
if (moved) {
|
||||
setInstrumentParams(params);
|
||||
}
|
||||
// The hold outranks the model only until the model carries the point (this directory's
|
||||
// CLAUDE.md, THE AUTHORITY MODEL) — `releaseProof` is that ordering enforced structurally:
|
||||
// `automation_.release` below cannot compile without one, and the only ways to obtain one are
|
||||
// `publishLiveParams`'s return (the `moved` branch) or `noRepublishNeeded` (nothing to
|
||||
// publish because the fold left the model already matching the point). Built via an
|
||||
// immediately-invoked lambda rather than `moved ? publishLiveParams() : noRepublishNeeded()`
|
||||
// directly: ReleaseProof's copy constructor is deleted (automation_channel.h), and a ternary
|
||||
// between two same-type prvalue arms needs it to merge them into one value — each `return`
|
||||
// below is instead its OWN guaranteed-elided construction of the function's result object.
|
||||
const AutomationChannel::ReleaseProof releaseProof = [&]() -> AutomationChannel::ReleaseProof {
|
||||
if (moved) return publishLiveParams();
|
||||
return AutomationChannel::ReleaseProof::noRepublishNeeded();
|
||||
}();
|
||||
syncParamsFromModel();
|
||||
paramNotifySuppressed_ = wasSuppressed;
|
||||
for (std::size_t i = 0; i < foldedCount; ++i) {
|
||||
automation_.release(foldedSlots[i], foldedSeqs[i], releaseProof);
|
||||
}
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::endParamGesture() {
|
||||
gestureLatching_ = false;
|
||||
if (openGestureCount_ == 0) return;
|
||||
// Latched into a local and the state cleared FIRST: endEdit can re-enter through a host's
|
||||
// own callback, and must not find a bracket this call is in the middle of closing.
|
||||
param::ParamId closing[kMaxOpenGestures];
|
||||
const std::size_t count = openGestureCount_;
|
||||
for (std::size_t i = 0; i < count; ++i) closing[i] = openGestureIds_[i];
|
||||
openGestureCount_ = 0;
|
||||
for (std::size_t i = 0; i < count; ++i) endEdit(closing[i]);
|
||||
}
|
||||
|
||||
} // namespace reasampler::vst
|
||||
@@ -146,18 +146,25 @@ std::string ReaSamplerProcessor::reloadInstrument() {
|
||||
buildFromRef(*sel, params, projectDir, mode)) {
|
||||
sample = std::move(*decoded);
|
||||
havePlayable = true;
|
||||
// Point the built snapshot at the instance's ONE live block and seed it from
|
||||
// the very PlayParams the voices latch, so an untouched knob folds to the same
|
||||
// frames the build resolved and a note-on with a live block sounds identical
|
||||
// to one without.
|
||||
sample.live = &liveParams_;
|
||||
// Point the built snapshot at the instance's ONE engine-facing block and seed the
|
||||
// MODEL block from the very PlayParams the voices latch, so an untouched knob folds
|
||||
// to the same frames the build resolved and a note-on with a live block sounds
|
||||
// identical to one without. process() merges the seed into automationLive_ at the
|
||||
// top of the first block after this, which is where the swap below becomes visible
|
||||
// too — so the new snapshot's first note reads it.
|
||||
sample.live = &automationLive_;
|
||||
// BEFORE the publish: the publish is what makes the audio thread re-merge, and the
|
||||
// merge resolves every held automation stage time against this rate. Stored after,
|
||||
// a merge in the window between them would resolve them against the previous
|
||||
// capture's rate — or, on the first-ever build, against 0, where secondsToFrames
|
||||
// collapses every automated envelope stage to zero frames.
|
||||
builtSampleRate_.store(sample.sampleRate, std::memory_order_relaxed);
|
||||
{
|
||||
// reloadMutex_ (held for this whole function) nests livePublishMutex_ here;
|
||||
// publishLiveParams never holds reloadMutex_, so this is the only nesting.
|
||||
std::lock_guard<std::mutex> lp(livePublishMutex_);
|
||||
liveParams_.publish(instrument::engine::foldLive(sample.play));
|
||||
liveParams_.publish(instrument::engine::foldLive(sample.play, sample.keyTrack));
|
||||
}
|
||||
builtSampleRate_.store(sample.sampleRate, std::memory_order_relaxed);
|
||||
resolvedId = selId; // the concrete pick that resolved
|
||||
}
|
||||
}
|
||||
@@ -226,6 +233,8 @@ void ReaSamplerProcessor::adoptBakedCapture(const SampleRefEntry& entry,
|
||||
// bake chain only ever runs from that tick, so the arm would be drained on the next one
|
||||
// anyway. At the tail for the same reason setState's is (see there).
|
||||
flushLatencyRestart();
|
||||
// The reset's gain notification, armed under reloadMutex_ inside that reload.
|
||||
flushGainNotify();
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::publishUsage(const SampleRefs& refs,
|
||||
@@ -289,8 +298,16 @@ void ReaSamplerProcessor::publishBuiltLocked(std::unique_ptr<LoadedInstrument> b
|
||||
// gain sitting above every snapshot, the same shape as ONE BLOCK, ONE RATE (see
|
||||
// builtSampleRate_).
|
||||
if (gainAtNextPublish_) {
|
||||
setMasterGainLinear(*gainAtNextPublish_);
|
||||
// The MIRROR is inline (that is the sound this publish belongs to); the host
|
||||
// notification is ARMED and delivered after reloadMutex_ is released. performEdit
|
||||
// reaches the host handler, a host may re-enter this object synchronously from it, and
|
||||
// setActive(false) takes this same non-recursive mutex — the identical hazard the
|
||||
// latency restart is deferred for.
|
||||
const bool moved = publishMasterGainLinear(*gainAtNextPublish_);
|
||||
gainAtNextPublish_.reset();
|
||||
// Armed only on a real change, so a reset that lands on the gain already set writes
|
||||
// nothing into a host's automation lane — the same compare setMasterGainLinear makes.
|
||||
if (moved) gainNotifyPending_.store(true, std::memory_order_release);
|
||||
}
|
||||
LoadedInstrument* evicted = draining_.exchange(prev);
|
||||
if (evicted) graveyard_.push_back(std::unique_ptr<LoadedInstrument>(evicted));
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// processor_snapshot.h — the two namespace-scope aggregates the processor hands ACROSS its
|
||||
// thread boundary: the loaded instrument the audio thread renders, and the bus state it
|
||||
// publishes back for the editor's meter. Neither is behaviour, which is what makes them a
|
||||
// responsibility rather than a bisection of the processor's own declaration.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
#include "core/instrument/engine/voice_engine.h"
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
// What the audio thread publishes about the OUTPUT BUS, post-limiter, once per block. Raw
|
||||
// magnitudes only — the UI converts to dB and runs the ballistics (engine/meter_ballistics),
|
||||
// because a hold timer or a log on the audio thread would be per-block work that buys nothing.
|
||||
struct MasterBusMeter {
|
||||
float peakL = 0.f; // max |x| this block
|
||||
float peakR = 0.f;
|
||||
// Smallest gain the LIMITER computed this block (Limiter::process) — deliberately NOT
|
||||
// scaled by the transition mute, so a toggle over quiet material reads 1 (no reduction)
|
||||
// rather than the mute's own weight. 1 = no reduction.
|
||||
float minGain = 1.f;
|
||||
bool clip = false; // LATCHED at a block peak >= 0 dBFS; only clearMasterBusClip lowers it
|
||||
};
|
||||
|
||||
// The decoded capture + the voice engine playing it. The engine holds a reference to the
|
||||
// sample, so both must live/die together at a stable address — heap-allocated,
|
||||
// non-copyable, non-movable. process() only ever reads this through an atomic pointer.
|
||||
struct LoadedInstrument {
|
||||
SampleData sample;
|
||||
VoiceEngine engine;
|
||||
std::uint64_t installedAt = 0; // reloadGeneration_ at which this was installed into live_
|
||||
|
||||
// Takeover declick is on by default here (product default; the pure core defaults it
|
||||
// off): any voice restart (mono retrigger, legato, poly steal, preview) ramps instead
|
||||
// of clicking.
|
||||
LoadedInstrument(SampleData sd, std::size_t maxVoices,
|
||||
std::uint64_t gen, std::size_t preserveVoiceCap = 0,
|
||||
std::int64_t preserveWindowFrames = 0,
|
||||
VoiceMode voiceMode = VoiceMode::Poly,
|
||||
MonoTrigger monoTrigger = MonoTrigger::Retrigger)
|
||||
: sample(std::move(sd)),
|
||||
engine(maxVoices, sample, preserveVoiceCap, preserveWindowFrames,
|
||||
voiceMode, monoTrigger, /*takeoverDeclick=*/true),
|
||||
installedAt(gen) {}
|
||||
|
||||
// True when nothing in this snapshot is sounding; lets the off-thread retirer park an
|
||||
// idle drain early. Bounded scan (<= maxVoices).
|
||||
bool fullyIdle() const { return engine.activeVoiceCount() == 0; }
|
||||
|
||||
LoadedInstrument(const LoadedInstrument&) = delete;
|
||||
LoadedInstrument& operator=(const LoadedInstrument&) = delete;
|
||||
};
|
||||
|
||||
} // 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
|
||||
// frames->seconds conversion needs.
|
||||
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);
|
||||
setInstrumentParams(cs.params);
|
||||
// Restore the last-consumed assignment generation so a re-open does not re-apply a
|
||||
@@ -86,15 +90,27 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) {
|
||||
// A new blob is new facts — the legacy lift gets one fresh run per restored state.
|
||||
legacyLiftConcluded_.store(false, std::memory_order_relaxed);
|
||||
reloadInstrument();
|
||||
paramNotifySuppressed_ = false;
|
||||
// Every exposed parameter now reads the blob's value. Its ordering against the host's first
|
||||
// parameter block does not need to be known: an automation point held by the audio thread is
|
||||
// re-applied over every merge, so a lane outranks this restore whichever way round the two
|
||||
// arrive. That is VST3's own rule — a written lane outranks anything the plug-in sets — not
|
||||
// a race we lost.
|
||||
syncParamsFromModel();
|
||||
// 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
|
||||
// against the refs above, which are only fully restored once this function has run to here.
|
||||
flushLatencyRestart();
|
||||
flushGainNotify();
|
||||
return kResultOk;
|
||||
}
|
||||
|
||||
tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) {
|
||||
if (!state) return kResultFalse;
|
||||
// Before the snapshot, not after: the blob is authoritative, so anything the host's
|
||||
// automation wrote must be in the model by the time it is serialised. This is the one drain
|
||||
// site that is not an optimisation — a save with no editor open still has to see it.
|
||||
drainAutomationToModel();
|
||||
// Persists the full instance state — never written to the "reasampler" bank ext-state.
|
||||
// No pick serializes to {"", default params}, restoring as silence (never auto-playing
|
||||
// sample #1).
|
||||
@@ -152,10 +168,34 @@ InstrumentParams ReaSamplerProcessor::instrumentParams() {
|
||||
return params_;
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::setInstrumentParams(const InstrumentParams& params) {
|
||||
std::uint32_t ReaSamplerProcessor::instrumentParamsGeneration() {
|
||||
std::lock_guard<std::mutex> lock(paramsMutex_);
|
||||
return paramsGeneration_;
|
||||
}
|
||||
|
||||
InstrumentParams ReaSamplerProcessor::instrumentParams(std::uint32_t& generation) {
|
||||
std::lock_guard<std::mutex> lock(paramsMutex_);
|
||||
generation = paramsGeneration_;
|
||||
return params_;
|
||||
}
|
||||
|
||||
std::uint32_t ReaSamplerProcessor::setInstrumentParams(const InstrumentParams& params) {
|
||||
// The exposed values alone, not the whole set: this funnel fires per mouse move on every live
|
||||
// knob and node drag, and InstrumentParams owns seven vectors — copying all of them to diff
|
||||
// 44 doubles is the cost, and the diff is what the notification actually needs.
|
||||
double before[kDeckParamSlots];
|
||||
std::uint32_t generation;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(paramsMutex_);
|
||||
for (const instrument::param::ParamRow& row : instrument::param::exposedParams()) {
|
||||
before[static_cast<std::size_t>(row.deck)] = modelParamNormalized(params_, row.deck);
|
||||
}
|
||||
params_ = params;
|
||||
// Bumped inside the lock with the write it names, so a reader taking the pair together
|
||||
// can never see a generation that does not describe the set beside it. Read back before
|
||||
// the unlock for the same reason — a caller wanting ITS OWN write's generation must not
|
||||
// race a second writer's bump between this function's unlock and its return.
|
||||
generation = ++paramsGeneration_;
|
||||
}
|
||||
// Every writer of the parameter set — setState, the editor's commits, the bake's adopt —
|
||||
// funnels through here, so mirroring the limiter flag at this one point is what keeps the
|
||||
@@ -173,6 +213,11 @@ void ReaSamplerProcessor::setInstrumentParams(const InstrumentParams& params) {
|
||||
latencyRestartPending_.store(
|
||||
params.limiterEnabled != latencyAnnounced_.load(std::memory_order_relaxed),
|
||||
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);
|
||||
return generation;
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::flushLatencyRestart() {
|
||||
@@ -231,15 +276,21 @@ void ReaSamplerProcessor::clearMasterBusClip() {
|
||||
meterClip_.store(false, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::publishLiveParams() {
|
||||
AutomationChannel::ReleaseProof ReaSamplerProcessor::publishLiveParams() {
|
||||
const int rate = builtSampleRate_.load(std::memory_order_relaxed);
|
||||
if (rate <= 0) return;
|
||||
if (rate <= 0) return AutomationChannel::ReleaseProof::noRepublishNeeded();
|
||||
const std::uint32_t before = liveParams_.generation();
|
||||
const InstrumentParams params = instrumentParams();
|
||||
const instrument::engine::LiveValues block =
|
||||
instrument::engine::foldLive(resolvePlay(instrumentParams().play, rate));
|
||||
// livePublishMutex_ enforces the seqlock's single-writer contract (live_params.h) against
|
||||
// reloadInstrument's publish — held for the publish call only, not the fold above.
|
||||
std::lock_guard<std::mutex> lock(livePublishMutex_);
|
||||
liveParams_.publish(block);
|
||||
instrument::engine::foldLive(resolvePlay(params.play, rate), params.keyTrack);
|
||||
{
|
||||
// livePublishMutex_ enforces the seqlock's single-writer contract (live_params.h)
|
||||
// against reloadInstrument's publish — held for the publish call only, not the fold
|
||||
// above.
|
||||
std::lock_guard<std::mutex> lock(livePublishMutex_);
|
||||
liveParams_.publish(block);
|
||||
}
|
||||
return AutomationChannel::ReleaseProof::fromPublish(before, liveParams_.generation());
|
||||
}
|
||||
|
||||
SampleRefs ReaSamplerProcessor::sampleRefs() {
|
||||
@@ -313,13 +364,32 @@ void ReaSamplerProcessor::setMonoTrigger(MonoTrigger trigger) {
|
||||
rebuildVoiceEngine();
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::setMasterGainLinear(double linear) {
|
||||
bool ReaSamplerProcessor::publishMasterGainLinear(double linear) {
|
||||
// Clamp to the master_gain taper (0 = silence, cap = +24 dB). One relaxed atomic
|
||||
// store — no rebuild, no lock (a post-sum trim is not a keymap fact).
|
||||
if (!(linear >= 0.0)) linear = 0.0; // also catches NaN
|
||||
const double maxLin = masterGainMaxLinear();
|
||||
if (linear > maxLin) linear = maxLin;
|
||||
masterGain_.store(static_cast<float>(linear), std::memory_order_relaxed);
|
||||
const float value = static_cast<float>(linear);
|
||||
return masterGain_.exchange(value, std::memory_order_relaxed) != value;
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::setMasterGainLinear(double linear) {
|
||||
const bool moved = publishMasterGainLinear(linear);
|
||||
// Gain's own notification funnel — it is the one exposed control that does not ride the
|
||||
// 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_ || !moved) return;
|
||||
notifyParamChanged(instrument::param::kParamMasterGain,
|
||||
instrument::engine::masterGainNormFromLinear(masterGainLinear()));
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::flushGainNotify() {
|
||||
if (!componentHandler) return; // an arm raised before the handler connected waits
|
||||
if (!gainNotifyPending_.exchange(false, std::memory_order_acquire)) return;
|
||||
if (paramNotifySuppressed_) return;
|
||||
notifyParamChanged(instrument::param::kParamMasterGain,
|
||||
instrument::engine::masterGainNormFromLinear(masterGainLinear()));
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::previewNoteOn(int note) {
|
||||
|
||||
@@ -256,6 +256,13 @@ private:
|
||||
// liveCommitFor (deck_groups.h) for why.
|
||||
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
|
||||
// whatever is loaded, so a load swaps the sound, not the settings.
|
||||
void loadSelection(const std::string& id);
|
||||
@@ -398,8 +405,12 @@ private:
|
||||
// params edit, no reload).
|
||||
void applyDeckKnob(int id, double norm);
|
||||
|
||||
// The knob's live value label shown during hover/drag: milliseconds, percents, Hz, signed
|
||||
// semitones, a curve exponent, a voice count, or the master-gain dB.
|
||||
// The knob's value in the REAL unit the host is told about (core/instrument/param).
|
||||
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;
|
||||
|
||||
ReaSamplerProcessor* processor_ = nullptr;
|
||||
@@ -409,7 +420,11 @@ private:
|
||||
std::vector<BankChoice> banks_; // the named banks, for the filter tab strip
|
||||
std::vector<SampleChoice> visible_; // samples_ narrowed by the active bank filter
|
||||
std::string selectedId_; // the loaded capture ("" = empty state)
|
||||
InstrumentParams params_; // the ONE parameter set governing it
|
||||
InstrumentParams params_; // a CACHE of the processor's model (see below)
|
||||
// The model generation params_ was taken at. That copy is authoritative only for the
|
||||
// duration of a gesture; between gestures the sync tick re-seeds it whenever this differs
|
||||
// from the processor's, because a commit writes the WHOLE set back.
|
||||
std::uint32_t seenParamsGeneration_ = 0;
|
||||
ChannelMode channelMode_ = ChannelMode::Mono; // mono/stereo toggle snapshot
|
||||
|
||||
// Mirrors of the processor's persisted voice-system params, refreshed with the rest of the
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
#include "pluginterfaces/vst/ivstmidicontrollers.h" // kCtrlAllNotesOff / kCtrlAllSoundsOff (panic)
|
||||
#include "pluginterfaces/vst/vstspeaker.h"
|
||||
|
||||
#include "core/instrument/engine/master_gain.h" // the automation write of the gain's own atomic
|
||||
#include "core/instrument/param/param_merge.h" // the block-boundary merge decision
|
||||
#include "shell/instrument/reasampler_editor.h" // createView hands the host our IPlugView editor
|
||||
#include "shell/instrument/reasampler_embed.h" // embed shell + IReaperUIEmbedInterface (its iid DEF'd there)
|
||||
|
||||
@@ -69,6 +71,8 @@ tresult PLUGIN_API ReaSamplerProcessor::initialize(FUnknown* context) {
|
||||
addEventInput(STR16("MIDI In"), 16);
|
||||
addAudioOutput(STR16("Audio Out"), SpeakerArr::kStereo);
|
||||
|
||||
buildParameterList();
|
||||
|
||||
return kResultOk;
|
||||
}
|
||||
|
||||
@@ -198,6 +202,40 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
|
||||
(drain && drain->fullyIdle()) ? drain->installedAt : 0,
|
||||
std::memory_order_relaxed);
|
||||
|
||||
// Host automation, merged into the engine-facing block BEFORE the note marshalling below, so
|
||||
// a note-on in this block latches this block's values. See automationLive_ (header) for why
|
||||
// the merge is here rather than in the model's own publisher.
|
||||
{
|
||||
const bool dirty = drainInputParameterChanges(data.inputParameterChanges);
|
||||
if (dirty || liveParams_.generation() != seenModelGeneration_) {
|
||||
// BEFORE the block is read, and the ordering is load-bearing — automation_channel.h
|
||||
// owns why.
|
||||
automation_.refreshReleases();
|
||||
// Declared INSIDE the branch: LiveValues carries default member initializers, so a
|
||||
// block where nothing moved must not pay to construct one.
|
||||
instrument::engine::LiveValues merged;
|
||||
// The generation ACTUALLY observed, not the one sampled above: a publish landing
|
||||
// between the two would otherwise leave this thread re-merging an identical block
|
||||
// every following quiet one.
|
||||
const std::uint32_t observed = liveParams_.read(merged);
|
||||
if (observed != 0) {
|
||||
seenModelGeneration_ = observed;
|
||||
instrument::param::mergeAutomation(
|
||||
merged, automation_.slots(), kDeckParamSlots,
|
||||
builtSampleRate_.load(std::memory_order_relaxed));
|
||||
// The second gate, and a different window from the drain's own: this one catches
|
||||
// a model republish that changed nothing (a knob committed to the value it
|
||||
// already held, a released hold whose value the model now carries). Field-wise
|
||||
// — live_params.h owns why it must never become a memcmp.
|
||||
if (!haveMergedLive_ || merged != lastMergedLive_) {
|
||||
lastMergedLive_ = merged;
|
||||
haveMergedLive_ = true;
|
||||
automationLive_.publish(merged);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Marshal MIDI note-on/off at block granularity (no per-event sample-offset split;
|
||||
// sample-accurate scheduling is a later tier). Note-offs also route to the drain
|
||||
// engine so a note held across a reload releases its old-snapshot voice too.
|
||||
|
||||
@@ -17,13 +17,15 @@
|
||||
|
||||
#include "public.sdk/source/vst/vstsinglecomponenteffect.h"
|
||||
|
||||
#include "shell/instrument/automation_channel.h" // the automation hold + its release protocol
|
||||
#include "shell/instrument/processor_snapshot.h" // LoadedInstrument + MasterBusMeter
|
||||
#include "shell/instrument/reaper_bridge.h"
|
||||
#include "core/instrument/map/sample_map.h" // InstrumentParams (the one parameter set)
|
||||
#include "core/instrument/map/component_state_io.h" // ComponentState codec
|
||||
#include "core/instrument/engine/limiter.h" // the master bus's post-gain limiter
|
||||
#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/voice_engine.h"
|
||||
#include "core/instrument/param/param_id.h" // the frozen ParamId space + DeckParam binding
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
@@ -35,48 +37,6 @@ using instrument::map::kPreviewVelocityDefault;
|
||||
|
||||
class ReaSamplerEmbed; // embedded TCP/MCP UI shell (owned below; see queryInterface)
|
||||
|
||||
// What the audio thread publishes about the OUTPUT BUS, post-limiter, once per block. Raw
|
||||
// magnitudes only — the UI converts to dB and runs the ballistics (engine/meter_ballistics),
|
||||
// because a hold timer or a log on the audio thread would be per-block work that buys nothing.
|
||||
struct MasterBusMeter {
|
||||
float peakL = 0.f; // max |x| this block
|
||||
float peakR = 0.f;
|
||||
// Smallest gain the LIMITER computed this block (Limiter::process) — deliberately NOT
|
||||
// scaled by the transition mute, so a toggle over quiet material reads 1 (no reduction)
|
||||
// rather than the mute's own weight. 1 = no reduction.
|
||||
float minGain = 1.f;
|
||||
bool clip = false; // LATCHED at a block peak >= 0 dBFS; only clearMasterBusClip lowers it
|
||||
};
|
||||
|
||||
// The decoded capture + the voice engine playing it. The engine holds a reference to the
|
||||
// sample, so both must live/die together at a stable address — heap-allocated,
|
||||
// non-copyable, non-movable. process() only ever reads this through an atomic pointer.
|
||||
struct LoadedInstrument {
|
||||
SampleData sample;
|
||||
VoiceEngine engine;
|
||||
std::uint64_t installedAt = 0; // reloadGeneration_ at which this was installed into live_
|
||||
|
||||
// Takeover declick is on by default here (product default; the pure core defaults it
|
||||
// off): any voice restart (mono retrigger, legato, poly steal, preview) ramps instead
|
||||
// of clicking.
|
||||
LoadedInstrument(SampleData sd, std::size_t maxVoices,
|
||||
std::uint64_t gen, std::size_t preserveVoiceCap = 0,
|
||||
std::int64_t preserveWindowFrames = 0,
|
||||
VoiceMode voiceMode = VoiceMode::Poly,
|
||||
MonoTrigger monoTrigger = MonoTrigger::Retrigger)
|
||||
: sample(std::move(sd)),
|
||||
engine(maxVoices, sample, preserveVoiceCap, preserveWindowFrames,
|
||||
voiceMode, monoTrigger, /*takeoverDeclick=*/true),
|
||||
installedAt(gen) {}
|
||||
|
||||
// True when nothing in this snapshot is sounding; lets the off-thread retirer park an
|
||||
// idle drain early. Bounded scan (<= maxVoices).
|
||||
bool fullyIdle() const { return engine.activeVoiceCount() == 0; }
|
||||
|
||||
LoadedInstrument(const LoadedInstrument&) = delete;
|
||||
LoadedInstrument& operator=(const LoadedInstrument&) = delete;
|
||||
};
|
||||
|
||||
class ReaSamplerProcessor : public Steinberg::Vst::SingleComponentEffect {
|
||||
public:
|
||||
ReaSamplerProcessor() = default;
|
||||
@@ -124,6 +84,38 @@ public:
|
||||
// Hands the host our LICE IPlugView editor.
|
||||
Steinberg::IPlugView* PLUGIN_API createView(Steinberg::FIDString name) override;
|
||||
|
||||
// The CONTROLLER-side write — a host GUI gesture on the generic panel, and whatever a host
|
||||
// mirrors here for display. Applies it to THE model through that control's existing commit
|
||||
// tier 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.
|
||||
// NOT the automation channel; this directory's CLAUDE.md owns which channel is which.
|
||||
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();
|
||||
|
||||
// Folds what the audio thread took from the host's parameter queue back into the model and
|
||||
// the controller cache, suppressing the host notification (those values came FROM it), then
|
||||
// RELEASES each folded point's hold — which is what bounds a lane's authority to the window
|
||||
// where it is actually driving. UI/main thread; a no-op when nothing was automated. Called
|
||||
// wherever the model is about to be READ as authoritative — getState, the bake, the editor's
|
||||
// tick. This directory's CLAUDE.md owns the authority model.
|
||||
void drainAutomationToModel();
|
||||
|
||||
// A drag's host-edit bracket, so a host in touch or latch mode records ONE continuous edit
|
||||
// per parameter rather than a burst of one-point ones. Every parameter the drag notifies
|
||||
// opens its bracket on first touch and holds it until endParamGesture — LATCHING rather than
|
||||
// declared up front, because an envelope-node drag moves a set the grab cannot name. The
|
||||
// deck-knob form additionally opens its own id at once, which is the id the host sees a touch
|
||||
// on even if the drag produces no move. Idempotent. UI thread only.
|
||||
void beginParamGesture(instrument::ui::DeckParam deck);
|
||||
void beginParamGestureLatch();
|
||||
void endParamGesture();
|
||||
|
||||
// Additionally exposes REAPER's IReaperUIEmbedInterface (queried by REAPER to drive the
|
||||
// inline TCP/MCP embed); all other iids delegate to SingleComponentEffect unchanged.
|
||||
Steinberg::tresult PLUGIN_API queryInterface(const Steinberg::TUID iid,
|
||||
@@ -198,15 +190,21 @@ public:
|
||||
// never read on the audio thread — reloadInstrument bakes it into the SampleData
|
||||
// off-thread.
|
||||
InstrumentParams instrumentParams();
|
||||
void setInstrumentParams(const InstrumentParams& params);
|
||||
std::uint32_t setInstrumentParams(const InstrumentParams& params); // adopt THIS generation, never a second instrumentParamsGeneration() call
|
||||
|
||||
// The model's edit counter, bumped by every setInstrumentParams. A holder of a COPY (the
|
||||
// editor's snapshot) re-seeds when this moves under it; the overload answers both under one
|
||||
// lock, because reading them apart would let the pair disagree.
|
||||
std::uint32_t instrumentParamsGeneration();
|
||||
InstrumentParams instrumentParams(std::uint32_t& generation);
|
||||
|
||||
// Republishes the live-parameter block from the stored parameter set, resolved against the
|
||||
// rate the loaded capture was built at so an unmoved value folds to exactly the frames the
|
||||
// voices already latched. THE tier-3 commit (the three tiers are listed in this
|
||||
// directory's CLAUDE.md). Callers pair this with setInstrumentParams exactly as they
|
||||
// paired it with reloadInstrument. No-op before anything has been decoded (the next reload
|
||||
// bakes and publishes). UI thread; serialized against reloadInstrument's own publish.
|
||||
void publishLiveParams();
|
||||
// bakes and publishes). UI thread; serialized against reloadInstrument's own publish. Returns drainAutomationToModel's release proof (automation_channel.h); other callers ignore it.
|
||||
AutomationChannel::ReleaseProof publishLiveParams();
|
||||
|
||||
// Per-instance channel mode (mono | stereo), guarded by channelModeMutex_, never read
|
||||
// on the audio thread. Decode policy only (downmix vs L/R split) — the output bus is
|
||||
@@ -238,6 +236,9 @@ public:
|
||||
return static_cast<double>(masterGain_.load(std::memory_order_relaxed));
|
||||
}
|
||||
void setMasterGainLinear(double linear); // clamped to [0, masterGainMaxLinear()]
|
||||
// The mirror alone, with no host notification: for the one writer that runs under
|
||||
// reloadMutex_ and must arm rather than emit. True when the value actually moved.
|
||||
bool publishMasterGainLinear(double linear);
|
||||
|
||||
// The master-bus limiter's single enable (persisted in the parameter set). UI thread only:
|
||||
// a thin wrapper over setInstrumentParams, the one funnel that mirrors the flag onto the
|
||||
@@ -258,6 +259,11 @@ public:
|
||||
// same tick, so its arm would drain on the next one regardless).
|
||||
void flushLatencyRestart();
|
||||
|
||||
// Delivers the master-gain host notification a reload deferred (the bake's reset gain lands
|
||||
// under reloadMutex_, and performEdit may re-enter this object). Drained beside the latency
|
||||
// restart, from the same sites and for the same reason.
|
||||
void flushGainNotify();
|
||||
|
||||
// Fires a one-shot preview note-on/off through the live VoiceEngine — the same
|
||||
// noteOn/noteOff host MIDI uses, so a preview is a real voice (counts against voice
|
||||
// count, can steal/be stolen, respects Poly/Mono + Retrigger/Legato). Off the audio
|
||||
@@ -276,6 +282,38 @@ public:
|
||||
std::string usageInstanceGuid();
|
||||
|
||||
private:
|
||||
// Builds the unit and parameter lists from the derived exposed set. Called once, from
|
||||
// initialize().
|
||||
void buildParameterList();
|
||||
|
||||
// THE automation read, on the audio thread, at the BLOCK BOUNDARY: the last point of each
|
||||
// queue wins. RT-safe — relaxed/release atomic stores only. 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. True when at least one point landed ON A CONTROL THE BLOCK
|
||||
// CARRIES, which is what makes the merge below it conditional.
|
||||
bool drainInputParameterChanges(Steinberg::Vst::IParameterChanges* changes);
|
||||
|
||||
// The normalized value a control reads at, from the model — a projection of it, never a
|
||||
// cached shadow. Master gain reads the processor's own atomic, pitch key-track the scalar
|
||||
// beside the play bundle; everything else reads the parameter set through the deck's binding.
|
||||
double modelParamNormalized(const InstrumentParams& params,
|
||||
instrument::ui::DeckParam deck) const;
|
||||
|
||||
// The write peer of that read, and the ONE host-side write of a control's value: both the
|
||||
// controller's setParamNormalized and the audio thread's automation fold go through it, so
|
||||
// neither can miss a control whose value lives outside the parameter set.
|
||||
static void writeDeckParamToModel(InstrumentParams& params, instrument::ui::DeckParam deck,
|
||||
double normalized);
|
||||
|
||||
// Notifies the host of every exposed control whose normalized value moved. `beforeNorms` is
|
||||
// indexed by DeckParam ordinal. Called from setInstrumentParams — the ONE funnel every writer
|
||||
// already goes through — so no internal write can leave the host displaying, and on next
|
||||
// touch re-imposing, a superseded value. The bake's reset is the first non-gesture writer
|
||||
// this covers, and the reason the emission is grouped.
|
||||
void notifyParamsFromModel(const double* beforeNorms, const InstrumentParams& after);
|
||||
void notifyParamChanged(instrument::param::ParamId id, double normalized);
|
||||
bool gestureIsOpen(instrument::param::ParamId id) const;
|
||||
|
||||
// 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
|
||||
// tails die. Off the audio thread only (driven by pollBankSync); safe against a racing
|
||||
@@ -333,11 +371,39 @@ private:
|
||||
|
||||
ReaperBridge bridge_;
|
||||
|
||||
// The parameters whose drag bracket is currently open, and whether a drag is in flight at
|
||||
// all. UI thread only. Past the cap a write degrades to a one-point gesture rather than
|
||||
// dropping — the pre-bracket behaviour, not a new failure mode.
|
||||
static constexpr std::size_t kMaxOpenGestures = 8;
|
||||
instrument::param::ParamId openGestureIds_[kMaxOpenGestures] = {};
|
||||
std::size_t openGestureCount_ = 0;
|
||||
bool gestureLatching_ = false;
|
||||
// Set across setState so a LOAD is not reflected back to the host as an edit. Main thread
|
||||
// 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
|
||||
// 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
|
||||
// leave the drain's still-sounding voices deaf to the knob under them.
|
||||
instrument::engine::LiveParams liveParams_;
|
||||
// The block the ENGINE reads, and the ONE thing SampleData::live points at. Written only by
|
||||
// the audio thread, which merges liveParams_ with the host's automation points once per block
|
||||
// and republishes ONLY when the RESULT moved — so neither an unchanged model nor a lane
|
||||
// resending the value it already sent reaches the per-voice fan-out. A block carrying no
|
||||
// automation and no model change costs one relaxed load plus, when the host passed a non-null
|
||||
// IParameterChanges (`[verify — DAW]` REAPER's normal case), one cross-module
|
||||
// getParameterCount(). Two blocks because the seqlock's single-writer contract is load-bearing
|
||||
// and the two writers differ in thread; this directory's CLAUDE.md owns the argument.
|
||||
instrument::engine::LiveParams automationLive_;
|
||||
// Audio thread only. The last liveParams_ generation merged, the last block published (the
|
||||
// republish gate compares against it), and the automation slots themselves.
|
||||
std::uint32_t seenModelGeneration_ = 0;
|
||||
instrument::engine::LiveValues lastMergedLive_{};
|
||||
bool haveMergedLive_ = false;
|
||||
// The host lane's state and its release protocol; automation_channel.h owns the mechanism and
|
||||
// this directory's CLAUDE.md the authority model it enforces.
|
||||
AutomationChannel automation_;
|
||||
// Serializes liveParams_.publish's two writer sites (reloadInstrument, publishLiveParams)
|
||||
// only — separate from reloadMutex_ so a knob drag's publish never blocks behind a
|
||||
// reload's WAV decode. The audio thread never takes this; process() only reads via
|
||||
@@ -386,6 +452,10 @@ private:
|
||||
// the bake's reset, which the old capture would be the wrong thing to apply it to.
|
||||
// Guarded by reloadMutex_, consumed by publishBuiltLocked.
|
||||
std::optional<double> gainAtNextPublish_;
|
||||
// Armed when that deferred gain lands, delivered by flushGainNotify once reloadMutex_ is
|
||||
// released. Same shape and same reason as latencyRestartPending_ (see it): a host handler
|
||||
// callback must never run inside this mutex.
|
||||
std::atomic<bool> gainNotifyPending_{false};
|
||||
|
||||
// The decoded PCM parked across a deactivate, so an activation cycle costs no disk read
|
||||
// and no WAV decode: activation is "the audio thread may run", not "the sample is
|
||||
@@ -403,10 +473,13 @@ private:
|
||||
std::mutex selectionMutex_;
|
||||
std::string selectedSampleId_;
|
||||
|
||||
// The one parameter set. Off-thread only; reloadInstrument bakes it into the SampleData
|
||||
// under the reload lock, never read directly on the audio thread.
|
||||
// The one parameter set — THE model, and the authority every other holder of these values
|
||||
// defers to. Off-thread only; reloadInstrument bakes it into the SampleData under the reload
|
||||
// lock, never read directly on the audio thread. paramsGeneration_ moves with every write, so
|
||||
// a holder of a copy can tell that it has.
|
||||
std::mutex paramsMutex_;
|
||||
InstrumentParams params_;
|
||||
std::uint32_t paramsGeneration_ = 0;
|
||||
|
||||
// Instance-owned sample refs: path + intrinsics per referenced sample. Refreshed
|
||||
// opportunistically from the bank blob when readable; never a bank dependency for
|
||||
|
||||
@@ -210,7 +210,7 @@ int main() {
|
||||
{
|
||||
PlayParams slow = s.play;
|
||||
slow.trigAhd.attackFrames = 900;
|
||||
block.publish(instrument::engine::foldLive(slow));
|
||||
block.publish(instrument::engine::foldLive(slow, s.keyTrack));
|
||||
}
|
||||
|
||||
const BakePlan plan = planOf(/*total=*/1000, /*noteOn=*/0, /*noteOff=*/1000);
|
||||
|
||||
@@ -40,14 +40,19 @@ static void testEveryDeckControlIsClassifiedIntoOneOfTheThreeCommitTiers() {
|
||||
DeckParam::kFilterEnvAttackCurve, DeckParam::kFilterEnvDecayCurve,
|
||||
DeckParam::kFilterEnvReleaseCurve,
|
||||
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);
|
||||
|
||||
// The note-on-latched tier: published like a live control, read only at note-on. Asserted as
|
||||
// its OWN state rather than as "not Reload" — the whole point of widening the predicate is
|
||||
// that Rate must not fall back into either neighbour, and Γ-W4-T1 reads this classification
|
||||
// to decide what it exposes to the host.
|
||||
const DeckParam latched[] = {DeckParam::kRate};
|
||||
// that none of these falls back into either neighbour, and the VST3 parameter surface reads
|
||||
// this classification to decide what it exposes to the host.
|
||||
const DeckParam latched[] = {DeckParam::kRate, DeckParam::kKeyTrack,
|
||||
DeckParam::kTrigLength};
|
||||
for (DeckParam p : latched) CHECK(deckParamCommit(p) == LiveCommit::NoteOnLatched);
|
||||
|
||||
// Everything else reloads or rebuilds; deck_groups.h is the home for why each exclusion
|
||||
@@ -56,11 +61,10 @@ static void testEveryDeckControlIsClassifiedIntoOneOfTheThreeCommitTiers() {
|
||||
DeckParam::kPlayMode, DeckParam::kPitchEngine, DeckParam::kPitchEnvEnable,
|
||||
DeckParam::kFilterEnable, DeckParam::kFilterLaw,
|
||||
DeckParam::kAmpVelCurve, DeckParam::kPitchVelCurve, DeckParam::kFilterVelCurve,
|
||||
DeckParam::kKeyTrack, DeckParam::kTrigLength,
|
||||
DeckParam::kAmpEnvSelect, DeckParam::kPitchEnvSelect, DeckParam::kFilterEnvSelect,
|
||||
DeckParam::kAmpEnvMode, DeckParam::kPitchEnvMode, DeckParam::kFilterEnvMode,
|
||||
DeckParam::kVoiceCount, DeckParam::kVoiceMode,
|
||||
DeckParam::kMonoTrigger, DeckParam::kMasterGain, DeckParam::kLimiterEnable,
|
||||
DeckParam::kMonoTrigger, DeckParam::kLimiterEnable,
|
||||
DeckParam::kMasterMeter, DeckParam::kMasterGr,
|
||||
};
|
||||
for (DeckParam p : reloads) CHECK(deckParamCommit(p) == LiveCommit::Reload);
|
||||
@@ -95,8 +99,14 @@ static void testOnlyALiveControlsDragTakesTheLiveTier() {
|
||||
// Rate keeps its own tier through the drag site: it must not arrive as Live (which would let
|
||||
// it move a sounding note) nor as Reload (which would re-decode the WAV under a swept knob).
|
||||
CHECK(knob(DeckParam::kRate) == LiveCommit::NoteOnLatched);
|
||||
CHECK(knob(DeckParam::kTrigLength) == LiveCommit::Reload);
|
||||
CHECK(knob(DeckParam::kMasterGain) == LiveCommit::Reload);
|
||||
// Trigger length resolves playEnd_ and key-track the pitch ratio — both facts a voice fixes
|
||||
// at note-on, which is the latched tier's own definition rather than the reload tier's.
|
||||
CHECK(knob(DeckParam::kTrigLength) == LiveCommit::NoteOnLatched);
|
||||
CHECK(knob(DeckParam::kKeyTrack) == LiveCommit::NoteOnLatched);
|
||||
// Master gain is Live and reaches the audio BESIDE the live block rather than through it —
|
||||
// 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);
|
||||
// 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.
|
||||
|
||||
@@ -20,12 +20,6 @@ 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 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
|
||||
// must be the same number or a maxed knob stops landing on the canvas edge. Asserted, not assumed.
|
||||
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() {
|
||||
testTheTwoCeilingNamesAreOneNumber();
|
||||
testNormRoundTripsThroughEveryValueDomain();
|
||||
@@ -442,7 +414,6 @@ int main() {
|
||||
testShiftSnapsToAWholeUnitOfTheDisplayedValue();
|
||||
testAValueStoredUnderTheOldCeilingIsReadNotRewritten();
|
||||
testTheFilterFourKeepTheirIdentityTaper();
|
||||
testTimeConstantsAlwaysReadInMilliseconds();
|
||||
if (g_fail) {
|
||||
std::printf("%d FAILURE(S)\n", g_fail);
|
||||
return 1;
|
||||
|
||||
+126
-20
@@ -96,7 +96,7 @@ static Run renderWithLive(SampleData& sample, LiveParams* block, int blockFrames
|
||||
int changeAfter, const LiveValues* changed, int noteOffBlock = -1,
|
||||
int velocity = 100) {
|
||||
sample.live = block;
|
||||
if (block) block->publish(foldLive(sample.play));
|
||||
if (block) block->publish(foldLive(sample.play, sample.keyTrack));
|
||||
VoiceEngine engine(1, sample);
|
||||
engine.noteOn(kTestNote, velocity);
|
||||
Run r;
|
||||
@@ -320,7 +320,7 @@ static void testANoteStartedAfterAPublishSoundsThePublishedEnvelope() {
|
||||
SampleData s = periodicSine(200000, 64.0); // adsr default: attack 0, sustain 1.0
|
||||
LiveParams block;
|
||||
s.live = █
|
||||
LiveValues dialled = foldLive(s.play);
|
||||
LiveValues dialled = foldLive(s.play, s.keyTrack);
|
||||
dialled.adsr.attackFrames = 24000; // half a second of attack, dialled before the note
|
||||
block.publish(dialled);
|
||||
VoiceEngine engine(1, s);
|
||||
@@ -345,7 +345,7 @@ static void testANoteStartedAfterAPublishSoundsThePublishedEnvelope() {
|
||||
slow.play.adsr.attackFrames = 24000;
|
||||
LiveParams block2;
|
||||
slow.live = &block2;
|
||||
LiveValues snappy = foldLive(slow.play);
|
||||
LiveValues snappy = foldLive(slow.play, slow.keyTrack);
|
||||
snappy.adsr.attackFrames = 0;
|
||||
block2.publish(snappy);
|
||||
VoiceEngine fast(1, slow);
|
||||
@@ -368,7 +368,7 @@ static void assertLiveFieldMovesTheSoundingNote(const char* name, void (*rig)(Sa
|
||||
rig(still);
|
||||
rig(moved);
|
||||
LiveParams blockA, blockB;
|
||||
LiveValues target = foldLive(moved.play);
|
||||
LiveValues target = foldLive(moved.play, moved.keyTrack);
|
||||
mutate(target);
|
||||
|
||||
const Run baseline = renderWithLive(still, &blockA, 512, 24, -1, nullptr, noteOffBlock);
|
||||
@@ -616,7 +616,7 @@ static void testEveryLiveFilterControlMovesTheSoundingNote() {
|
||||
SampleData still = filteredSine();
|
||||
SampleData moved = filteredSine();
|
||||
LiveParams blockA, blockB;
|
||||
LiveValues target = foldLive(moved.play);
|
||||
LiveValues target = foldLive(moved.play, moved.keyTrack);
|
||||
c.mutate(target);
|
||||
|
||||
const Run baseline = renderWithLive(still, &blockA, 512, 24, -1, nullptr);
|
||||
@@ -688,7 +688,7 @@ static void testOneBlockServesTwoIndependentObservers() {
|
||||
LiveParams block;
|
||||
liveSnapshot.live = █
|
||||
drainSnapshot.live = █
|
||||
block.publish(foldLive(liveSnapshot.play));
|
||||
block.publish(foldLive(liveSnapshot.play, liveSnapshot.keyTrack));
|
||||
|
||||
VoiceEngine liveEngine(1, liveSnapshot);
|
||||
VoiceEngine drainEngine(1, drainSnapshot);
|
||||
@@ -696,7 +696,7 @@ static void testOneBlockServesTwoIndependentObservers() {
|
||||
drainEngine.noteOn(60, 100);
|
||||
|
||||
std::vector<AudioSample> a, b;
|
||||
LiveValues moved = foldLive(liveSnapshot.play);
|
||||
LiveValues moved = foldLive(liveSnapshot.play, liveSnapshot.keyTrack);
|
||||
moved.filterSettings.cutoffNorm = 0.2f;
|
||||
for (int blk = 0; blk < 24; ++blk) {
|
||||
if (blk == 8) block.publish(moved);
|
||||
@@ -746,7 +746,7 @@ static std::vector<AudioSample> renderPreserveCapable(SampleData& s, LiveParams&
|
||||
const LiveValues* changed, int changeAfter,
|
||||
int note) {
|
||||
s.live = █
|
||||
block.publish(foldLive(s.play));
|
||||
block.publish(foldLive(s.play, s.keyTrack));
|
||||
VoiceEngine engine(1, s, /*preserveVoiceCap=*/0, /*preserveWindowFrames=*/2048);
|
||||
engine.noteOn(note, 100);
|
||||
std::vector<AudioSample> out;
|
||||
@@ -765,7 +765,7 @@ static void testARateChangeSpareTheSoundingNoteAndReachesTheNextOne() {
|
||||
moved.play.pitchEngine = eng;
|
||||
|
||||
LiveParams blockA, blockB;
|
||||
LiveValues halfRate = foldLive(moved.play);
|
||||
LiveValues halfRate = foldLive(moved.play, moved.keyTrack);
|
||||
halfRate.playRate = 0.5;
|
||||
|
||||
// At the ROOT note, so Preserve's shifter runs at shift 1.0 and never splices — the
|
||||
@@ -795,7 +795,7 @@ static void testARateChangeSpareTheSoundingNoteAndReachesTheNextOne() {
|
||||
fresh.play.pitchEngine = eng;
|
||||
LiveParams block;
|
||||
fresh.live = █
|
||||
LiveValues published = foldLive(fresh.play);
|
||||
LiveValues published = foldLive(fresh.play, fresh.keyTrack);
|
||||
published.playRate = rate;
|
||||
block.publish(published);
|
||||
VoiceEngine engine(1, fresh, /*preserveVoiceCap=*/0, /*preserveWindowFrames=*/2048);
|
||||
@@ -827,7 +827,7 @@ static void testAPitchOffsetChangeMovesTheSoundingNoteInBothEngines() {
|
||||
moved.play.pitchEngine = eng;
|
||||
|
||||
LiveParams blockA, blockB;
|
||||
LiveValues target = foldLive(moved.play);
|
||||
LiveValues target = foldLive(moved.play, moved.keyTrack);
|
||||
target.pitchOffsetSemitones = -12.0;
|
||||
|
||||
const std::vector<AudioSample> baseline =
|
||||
@@ -859,7 +859,7 @@ static void testAPitchOffsetChangeMovesTheSoundingNoteInBothEngines() {
|
||||
static std::size_t soundingBlocksWithPublishedPitch(SampleData& s, double offsetSemis,
|
||||
std::size_t capFrames) {
|
||||
LiveParams block;
|
||||
LiveValues v = foldLive(s.play); // s.play keeps its own (zero) offset: the stale copy
|
||||
LiveValues v = foldLive(s.play, s.keyTrack); // s.play keeps its own (zero) offset: the stale copy
|
||||
v.pitchOffsetSemitones = offsetSemis;
|
||||
block.publish(v);
|
||||
s.live = █
|
||||
@@ -891,7 +891,7 @@ static void testAPublishedPitchOffsetLeavesTheStagedAttackWallClock() {
|
||||
s.play.trigAhd = AhdParams{kAttack, 0, 1.0, util::kCurveNeutral, util::kCurveNeutral};
|
||||
|
||||
LiveParams block;
|
||||
LiveValues v = foldLive(s.play);
|
||||
LiveValues v = foldLive(s.play, s.keyTrack);
|
||||
v.pitchOffsetSemitones = semis;
|
||||
block.publish(v);
|
||||
s.live = █
|
||||
@@ -938,6 +938,106 @@ static void testAPublishedPitchOffsetRefitsThePitchEnvelopeSpan() {
|
||||
if (!(life > 11000 && life < 13000)) std::printf(" refit span: life %zu\n", life);
|
||||
}
|
||||
|
||||
// Key-track is the second member of that class, and it is a PITCH-RATIO scalar: a sounding note
|
||||
// must not be retuned by it, the next note-on must take it. Measured at a note away from the root
|
||||
// (the ratio is 1.0 at the root whatever key-track says, so the root would prove nothing).
|
||||
static void testAKeyTrackChangeSparesTheSoundingNoteAndReachesTheNextOne() {
|
||||
SampleData still = rampForReadRate();
|
||||
SampleData moved = rampForReadRate();
|
||||
LiveParams blockA, blockB;
|
||||
LiveValues halfTrack = foldLive(moved.play, moved.keyTrack);
|
||||
halfTrack.keyTrack = 0.5; // half key-tracking: an octave up reads at ratio ~1.414, not 2.0
|
||||
|
||||
const std::vector<AudioSample> baseline =
|
||||
renderPreserveCapable(still, blockA, nullptr, -1, 72);
|
||||
const std::vector<AudioSample> swept =
|
||||
renderPreserveCapable(moved, blockB, &halfTrack, 8, 72);
|
||||
CHECK(baseline.size() == swept.size());
|
||||
bool untouched = true;
|
||||
for (std::size_t i = 0; i < baseline.size() && i < swept.size(); ++i) {
|
||||
if (baseline[i] != swept[i]) { untouched = false; break; }
|
||||
}
|
||||
CHECK(untouched);
|
||||
|
||||
// The next note-on takes it, read straight off the ramp: under Varispeed the output value at
|
||||
// frame i IS the read position, so the slope over one block is the pitch ratio.
|
||||
auto slopePerFrame = [&](double keyTrack) {
|
||||
SampleData fresh = rampForReadRate();
|
||||
LiveParams block;
|
||||
fresh.live = █
|
||||
LiveValues published = foldLive(fresh.play, fresh.keyTrack);
|
||||
published.keyTrack = keyTrack;
|
||||
block.publish(published);
|
||||
VoiceEngine engine(1, fresh);
|
||||
engine.noteOn(72, 100);
|
||||
std::vector<AudioSample> out;
|
||||
engine.render(out, 512);
|
||||
return (static_cast<double>(out.back()) - static_cast<double>(out.front())) /
|
||||
static_cast<double>(out.size() - 1) * 200000.0;
|
||||
};
|
||||
// kKeyTrackDefault is 1.0 — full tracking, so an octave up reads at 2.0.
|
||||
CHECK(std::fabs(slopePerFrame(1.0) - 2.0) < 0.01);
|
||||
CHECK(std::fabs(slopePerFrame(0.5) - std::pow(2.0, 0.5)) < 0.01);
|
||||
// And the value really is carried by the BLOCK: sample.play/keyTrack never moved.
|
||||
CHECK(std::fabs(slopePerFrame(0.0) - 1.0) < 0.01);
|
||||
}
|
||||
|
||||
// Trigger length is the third: it resolves playEnd_, so it re-spans the NEXT note and leaves the
|
||||
// sounding one at the span it was struck with.
|
||||
static void testATriggerLengthChangeSparesTheSoundingNoteAndReachesTheNextOne() {
|
||||
auto triggerSource = [] {
|
||||
SampleData s = rampForReadRate();
|
||||
s.play.playMode = PlayMode::Trigger;
|
||||
s.play.trigger.lengthFraction = 1.0;
|
||||
s.play.trigAhd.holdFraction = 1.0; // flat through the span, so the span IS the lifetime
|
||||
return s;
|
||||
};
|
||||
// The note's LIFETIME is what the fraction spans, so blocks-alive measures it directly.
|
||||
auto blocksAlive = [&](double fraction) {
|
||||
SampleData fresh = triggerSource();
|
||||
LiveParams block;
|
||||
fresh.live = █
|
||||
LiveValues published = foldLive(fresh.play, fresh.keyTrack);
|
||||
published.lengthFraction = fraction;
|
||||
block.publish(published);
|
||||
VoiceEngine engine(1, fresh);
|
||||
engine.noteOn(60, 100);
|
||||
std::vector<AudioSample> out;
|
||||
int blocks = 0;
|
||||
while (engine.activeVoiceCount() > 0 && blocks < 4000) {
|
||||
engine.render(out, 512);
|
||||
++blocks;
|
||||
}
|
||||
return blocks;
|
||||
};
|
||||
const int whole = blocksAlive(1.0);
|
||||
const int quarterSpan = blocksAlive(0.25);
|
||||
CHECK(whole > 100 && whole < 4000);
|
||||
CHECK(std::fabs(static_cast<double>(quarterSpan) - 0.25 * whole) < 0.05 * whole);
|
||||
|
||||
// And the sounding note is spared. The published fraction is small enough that its span ENDS
|
||||
// inside the window rendered — asserted, not assumed, because a fraction whose playEnd_ still
|
||||
// sat past the render would leave the two runs identical whether the field were live or not.
|
||||
constexpr int kSweepBlocks = 24; // renderPreserveCapable's own loop count
|
||||
CHECK(blocksAlive(0.05) < kSweepBlocks);
|
||||
|
||||
SampleData still = triggerSource();
|
||||
SampleData moved = triggerSource();
|
||||
LiveParams blockA, blockB;
|
||||
LiveValues shortened = foldLive(moved.play, moved.keyTrack);
|
||||
shortened.lengthFraction = 0.05;
|
||||
const std::vector<AudioSample> baseline =
|
||||
renderPreserveCapable(still, blockA, nullptr, -1, 60);
|
||||
const std::vector<AudioSample> swept =
|
||||
renderPreserveCapable(moved, blockB, &shortened, 8, 60);
|
||||
CHECK(baseline.size() == swept.size());
|
||||
bool untouched = true;
|
||||
for (std::size_t i = 0; i < baseline.size() && i < swept.size(); ++i) {
|
||||
if (baseline[i] != swept[i]) { untouched = false; break; }
|
||||
}
|
||||
CHECK(untouched);
|
||||
}
|
||||
|
||||
// --- What stays latched at note-on -------------------------------------------------------
|
||||
|
||||
static void testPitchRatioAndVelocityGainStayLatched() {
|
||||
@@ -960,14 +1060,18 @@ static void testPitchRatioAndVelocityGainStayLatched() {
|
||||
|
||||
LiveParams block;
|
||||
s.live = █
|
||||
block.publish(foldLive(s.play));
|
||||
block.publish(foldLive(s.play, s.keyTrack));
|
||||
VoiceEngine engine(1, s);
|
||||
engine.noteOn(72, 64); // an octave up: ratio 2.0
|
||||
|
||||
std::vector<AudioSample> out;
|
||||
LiveValues hostile = foldLive(s.play);
|
||||
// Everything the block CAN carry, moved as far as it goes. None of it names velocity, the
|
||||
// note, the pitch ratio, or the PCM — that is the property under test.
|
||||
LiveValues hostile = foldLive(s.play, s.keyTrack);
|
||||
// Everything the block CAN carry, moved as far as it goes. keyTrack and lengthFraction DO
|
||||
// name the pitch ratio and the play span — they are here precisely because a SOUNDING voice
|
||||
// must not read either, which is what makes them note-on-latched rather than live; the tests
|
||||
// above are what prove the next note does take them.
|
||||
hostile.keyTrack = 0.0;
|
||||
hostile.lengthFraction = 0.05;
|
||||
hostile.filterKeyTrack = 2.0;
|
||||
hostile.filterSettings.cutoffNorm = 0.0f;
|
||||
hostile.filterModAmount = 1.0;
|
||||
@@ -998,11 +1102,11 @@ static void testPitchRatioAndVelocityGainStayLatched() {
|
||||
SampleData s2 = s;
|
||||
LiveParams block2;
|
||||
s2.live = &block2;
|
||||
block2.publish(foldLive(s2.play));
|
||||
block2.publish(foldLive(s2.play, s2.keyTrack));
|
||||
VoiceEngine engine2(1, s2);
|
||||
engine2.noteOn(72, 64);
|
||||
std::vector<AudioSample> out2;
|
||||
LiveValues quieter = foldLive(s2.play);
|
||||
LiveValues quieter = foldLive(s2.play, s2.keyTrack);
|
||||
quieter.adsr.sustainLevel = 0.25;
|
||||
for (int blk = 0; blk < 8; ++blk) {
|
||||
if (blk == 2) block2.publish(quieter);
|
||||
@@ -1024,7 +1128,7 @@ static void testVelocityGainSurvivesAHostilePublishThatReallyLands() {
|
||||
rig.play.pitchEnv.shape.decayFrames = 24000;
|
||||
rig.play.pitchEnv.peakSemitones = 3.0;
|
||||
|
||||
LiveValues hostile = foldLive(rig.play);
|
||||
LiveValues hostile = foldLive(rig.play, rig.keyTrack);
|
||||
hostile.filterKeyTrack = 2.0;
|
||||
hostile.filterSettings.cutoffNorm = 0.9f;
|
||||
hostile.filterModAmount = -1.0;
|
||||
@@ -1078,6 +1182,8 @@ int main() {
|
||||
testEveryLiveFilterControlMovesTheSoundingNote();
|
||||
testOneBlockServesTwoIndependentObservers();
|
||||
testARateChangeSpareTheSoundingNoteAndReachesTheNextOne();
|
||||
testAKeyTrackChangeSparesTheSoundingNoteAndReachesTheNextOne();
|
||||
testATriggerLengthChangeSparesTheSoundingNoteAndReachesTheNextOne();
|
||||
testAPitchOffsetChangeMovesTheSoundingNoteInBothEngines();
|
||||
testAPublishedPitchOffsetLeavesTheStagedAttackWallClock();
|
||||
testAPublishedPitchOffsetRefitsThePitchEnvelopeSpan();
|
||||
|
||||
@@ -41,7 +41,9 @@ static void testFoldCarriesEveryContinuousControl() {
|
||||
p.pitchEnv.shape = AhdParams{7, 9, 0.4, 1.5, 0.75};
|
||||
p.pitchEnv.peakSemitones = -3.5;
|
||||
|
||||
const LiveValues v = foldLive(p);
|
||||
// Distinct from filter.keyTrack below on purpose: the two are different controls and a fold
|
||||
// that crossed them would pass under a shared value.
|
||||
const LiveValues v = foldLive(p, /*keyTrack=*/0.8);
|
||||
CHECK(v.adsr.attackFrames == 11);
|
||||
CHECK(v.adsr.holdFrames == 22);
|
||||
CHECK(v.adsr.decayFrames == 33);
|
||||
@@ -72,6 +74,83 @@ static void testFoldCarriesEveryContinuousControl() {
|
||||
CHECK(v.pitchEnv.shape.attackCurve == 1.5);
|
||||
CHECK(v.pitchEnv.shape.decayCurve == 0.75);
|
||||
CHECK(v.pitchEnv.peakSemitones == -3.5);
|
||||
CHECK(v.keyTrack == 0.8);
|
||||
// Spline-folded on the way in, so the block carries what the voice will actually play.
|
||||
CHECK(!v.splineActive);
|
||||
CHECK(v.lengthFraction == p.trigger.lengthFraction);
|
||||
}
|
||||
|
||||
// The fold, not the voice, is where a drawn contour pins the Trigger span — so the block a
|
||||
// note-on latches already carries the folded value.
|
||||
static void testADrawnEnvelopePinsTheFoldedTriggerLength() {
|
||||
PlayParams p;
|
||||
p.trigger.lengthFraction = 0.25;
|
||||
p.ampSpline.mode = EnvMode::Spline;
|
||||
const LiveValues v = foldLive(p, kKeyTrackDefault);
|
||||
CHECK(v.splineActive);
|
||||
CHECK(v.lengthFraction == 1.0);
|
||||
}
|
||||
|
||||
// Poisons the block ONE LEAF FIELD AT A TIME and checks operator== catches every one — the half
|
||||
// that actually catches a forgotten field, since the static_assert above only fires when a
|
||||
// member changes sizeof(LiveValues), which padding can absorb. Covers every leaf of every
|
||||
// nested struct, not just the 14 top-level members, so a member dropped from sameAdsr/sameAhd/
|
||||
// sameFilterSettings is caught here too, not just a member dropped from operator== itself.
|
||||
static void testEveryFieldOfLiveValuesIsCompared() {
|
||||
using instrument::engine::filter::MorphLaw;
|
||||
const LiveValues base{};
|
||||
auto poisoned = [&](auto mutate) {
|
||||
LiveValues v = base;
|
||||
mutate(v);
|
||||
return v;
|
||||
};
|
||||
CHECK(base == base);
|
||||
CHECK(poisoned([](LiveValues& v) { v.filterSettings.cutoffNorm += 0.1f; }) != base);
|
||||
CHECK(poisoned([](LiveValues& v) { v.filterSettings.resonanceNorm += 0.1f; }) != base);
|
||||
CHECK(poisoned([](LiveValues& v) { v.filterSettings.morphNorm += 0.1f; }) != base);
|
||||
CHECK(poisoned([](LiveValues& v) { v.filterSettings.driveNorm += 0.1f; }) != base);
|
||||
CHECK(poisoned([](LiveValues& v) { v.filterSettings.morphLaw = MorphLaw::HighNotchLow; }) != base);
|
||||
CHECK(poisoned([](LiveValues& v) { v.filterModAmount += 0.1; }) != base);
|
||||
CHECK(poisoned([](LiveValues& v) { v.filterVelAmount += 0.1; }) != base);
|
||||
CHECK(poisoned([](LiveValues& v) { v.filterKeyTrack += 0.1; }) != base);
|
||||
CHECK(poisoned([](LiveValues& v) { v.filterEnv.attackFrames += 1; }) != base);
|
||||
CHECK(poisoned([](LiveValues& v) { v.filterEnv.holdFrames += 1; }) != base);
|
||||
CHECK(poisoned([](LiveValues& v) { v.filterEnv.decayFrames += 1; }) != base);
|
||||
CHECK(poisoned([](LiveValues& v) { v.filterEnv.sustainLevel += 0.1; }) != base);
|
||||
CHECK(poisoned([](LiveValues& v) { v.filterEnv.releaseFrames += 1; }) != base);
|
||||
CHECK(poisoned([](LiveValues& v) { v.filterEnv.attackCurve += 0.1; }) != base);
|
||||
CHECK(poisoned([](LiveValues& v) { v.filterEnv.decayCurve += 0.1; }) != base);
|
||||
CHECK(poisoned([](LiveValues& v) { v.filterEnv.releaseCurve += 0.1; }) != base);
|
||||
CHECK(poisoned([](LiveValues& v) { v.filterAhd.attackFrames += 1; }) != base);
|
||||
CHECK(poisoned([](LiveValues& v) { v.filterAhd.decayFrames += 1; }) != base);
|
||||
CHECK(poisoned([](LiveValues& v) { v.filterAhd.holdFraction += 0.1; }) != base);
|
||||
CHECK(poisoned([](LiveValues& v) { v.filterAhd.attackCurve += 0.1; }) != base);
|
||||
CHECK(poisoned([](LiveValues& v) { v.filterAhd.decayCurve += 0.1; }) != base);
|
||||
CHECK(poisoned([](LiveValues& v) { v.adsr.attackFrames += 1; }) != base);
|
||||
CHECK(poisoned([](LiveValues& v) { v.adsr.holdFrames += 1; }) != base);
|
||||
CHECK(poisoned([](LiveValues& v) { v.adsr.decayFrames += 1; }) != base);
|
||||
CHECK(poisoned([](LiveValues& v) { v.adsr.sustainLevel += 0.1; }) != base);
|
||||
CHECK(poisoned([](LiveValues& v) { v.adsr.releaseFrames += 1; }) != base);
|
||||
CHECK(poisoned([](LiveValues& v) { v.adsr.attackCurve += 0.1; }) != base);
|
||||
CHECK(poisoned([](LiveValues& v) { v.adsr.decayCurve += 0.1; }) != base);
|
||||
CHECK(poisoned([](LiveValues& v) { v.adsr.releaseCurve += 0.1; }) != base);
|
||||
CHECK(poisoned([](LiveValues& v) { v.ampAhd.attackFrames += 1; }) != base);
|
||||
CHECK(poisoned([](LiveValues& v) { v.ampAhd.decayFrames += 1; }) != base);
|
||||
CHECK(poisoned([](LiveValues& v) { v.ampAhd.holdFraction += 0.1; }) != base);
|
||||
CHECK(poisoned([](LiveValues& v) { v.ampAhd.attackCurve += 0.1; }) != base);
|
||||
CHECK(poisoned([](LiveValues& v) { v.ampAhd.decayCurve += 0.1; }) != base);
|
||||
CHECK(poisoned([](LiveValues& v) { v.pitchEnv.enabled = !v.pitchEnv.enabled; }) != base);
|
||||
CHECK(poisoned([](LiveValues& v) { v.pitchEnv.peakSemitones += 0.1; }) != base);
|
||||
CHECK(poisoned([](LiveValues& v) { v.pitchEnv.shape.attackFrames += 1; }) != base);
|
||||
CHECK(poisoned([](LiveValues& v) { v.pitchEnv.shape.decayFrames += 1; }) != base);
|
||||
CHECK(poisoned([](LiveValues& v) { v.pitchEnv.shape.holdFraction += 0.1; }) != base);
|
||||
CHECK(poisoned([](LiveValues& v) { v.pitchEnv.shape.attackCurve += 0.1; }) != base);
|
||||
CHECK(poisoned([](LiveValues& v) { v.pitchEnv.shape.decayCurve += 0.1; }) != base);
|
||||
CHECK(poisoned([](LiveValues& v) { v.playRate += 0.1; }) != base);
|
||||
CHECK(poisoned([](LiveValues& v) { v.pitchOffsetSemitones += 0.1; }) != base);
|
||||
CHECK(poisoned([](LiveValues& v) { v.keyTrack += 0.1; }) != base);
|
||||
CHECK(poisoned([](LiveValues& v) { v.lengthFraction += 0.1; }) != base);
|
||||
CHECK(poisoned([](LiveValues& v) { v.splineActive = !v.splineActive; }) != base);
|
||||
}
|
||||
|
||||
static void testUnpublishedBlockReadsAsNothing() {
|
||||
@@ -170,6 +249,8 @@ static void testRampStepIsRateDerived() {
|
||||
|
||||
int main() {
|
||||
testFoldCarriesEveryContinuousControl();
|
||||
testADrawnEnvelopePinsTheFoldedTriggerLength();
|
||||
testEveryFieldOfLiveValuesIsCompared();
|
||||
testUnpublishedBlockReadsAsNothing();
|
||||
testConcurrentReaderNeverSeesAHalfAppliedEdit();
|
||||
testRampTerminatesExactlyOnTheTarget();
|
||||
|
||||
@@ -90,18 +90,6 @@ static void testBelowFloorCollapsesToBottom() {
|
||||
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() {
|
||||
testBottomIsTrueSilence();
|
||||
testEndpoints();
|
||||
@@ -110,7 +98,6 @@ int main() {
|
||||
testMonotonic();
|
||||
testNonFiniteLinearClamps();
|
||||
testBelowFloorCollapsesToBottom();
|
||||
testLabels();
|
||||
if (g_fail) {
|
||||
std::printf("%d FAILURE(S)\n", g_fail);
|
||||
return 1;
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
// 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, each over the plain value ITS OWN surface derives (the
|
||||
// editor's exponent read goes to the stored field, not through the knob law). No VST3, no
|
||||
// REAPER, no framework.
|
||||
|
||||
#include "../src/core/instrument/param/param_format.h"
|
||||
|
||||
#include "../src/core/instrument/engine/master_gain.h"
|
||||
#include "../src/core/instrument/map/sample_map.h"
|
||||
#include "../src/core/instrument/param/param_id.h"
|
||||
#include "../src/core/instrument/param/param_units.h"
|
||||
#include "../src/core/instrument/ui/deck_values.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <limits>
|
||||
#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");
|
||||
}
|
||||
|
||||
// The EDITOR's derivation, spelled the way ReaSamplerEditor::deckPlainValue spells it — a curve
|
||||
// exponent is read off its stored field, never round-tripped through the knob law. Driving the
|
||||
// sweep through this rather than through toPlain(deckParamNorm(...)) is what makes the exponent
|
||||
// half of it a real assertion instead of a round trip on both sides.
|
||||
static double editorPlainValue(DeckParam deck, PlaySeconds& play) {
|
||||
using reasampler::instrument::ui::UnitCategory;
|
||||
if (reasampler::instrument::ui::deckParamUnit(deck) == UnitCategory::Exponent) {
|
||||
const double* stored = reasampler::instrument::ui::deckDoubleField(deck, play);
|
||||
return stored ? *stored : 0.0;
|
||||
}
|
||||
return toPlain(deck, reasampler::instrument::ui::deckParamNorm(deck, play));
|
||||
}
|
||||
|
||||
static void testTheEditorAndTheHostPrintTheSameDigitsAtTheSameStoredValue() {
|
||||
// The host derives its plain value from the normalized one it holds; the editor derives its
|
||||
// from the STORED field. 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 the parameter set carries.
|
||||
for (const ParamRow& row : exposedParams()) {
|
||||
if (valueHomeFor(row.deck) == ValueHome::InstanceScalar) continue; // own tests below
|
||||
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);
|
||||
const double storedNorm = reasampler::instrument::ui::deckParamNorm(row.deck, play);
|
||||
|
||||
if (row.deck == DeckParam::kFilterMorph) {
|
||||
// MORPH ALONE: its toPlain is full-double (`clamp01(n) * 100`), so the float the
|
||||
// model stores and the double the host holds are genuinely different inputs, and
|
||||
// at a value landing on a display rounding boundary that is worth one integer
|
||||
// percent. Cutoff/Q/drive cast to float INSIDE toPlain, so they are bit-identical
|
||||
// either way and are held to the string below like everything else.
|
||||
const double hostPlain = toPlain(row.deck, norm);
|
||||
const double editorPlain = toPlain(row.deck, storedNorm);
|
||||
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;
|
||||
}
|
||||
// A write the model CLAMPED (Trigger length's (0,1] floor) is not a settled state:
|
||||
// setParamNormalized caches what the model TOOK, so the host never holds the rejected
|
||||
// value. Only the four float-stored positions differ by a ulp rather than a clamp.
|
||||
if (!storesNormalized(row.deck) && storedNorm != norm) continue;
|
||||
|
||||
char hostBuf[24];
|
||||
char editorBuf[24];
|
||||
formatPlainFor(row.deck, toPlain(row.deck, norm), hostBuf, sizeof(hostBuf));
|
||||
formatPlainFor(row.deck, editorPlainValue(row.deck, play), editorBuf,
|
||||
sizeof(editorBuf));
|
||||
if (std::strcmp(hostBuf, editorBuf) != 0) {
|
||||
std::printf("FAIL param %u at norm %.4f: host \"%s\" vs editor \"%s\"\n",
|
||||
row.id, norm, hostBuf, editorBuf);
|
||||
++g_fail;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A continuous range stays continuous at the host boundary (Daniel, 2026-08-02): the knob
|
||||
// detent is a drag affordance only, so an exponent inside it but not exactly neutral — reachable
|
||||
// via an overlay knot drag — must read back true to the host, the same digits the editor shows.
|
||||
static void testAnOffDetentExponentReadsTrueToBothTheHostAndTheEditor() {
|
||||
PlaySeconds play;
|
||||
// The detent is +/-0.01 in NORM, which is a ~+/-0.047 band in the exponent — so 1.04 is
|
||||
// inside it and still prints as a distinct number.
|
||||
play.adsr.attackCurve = 1.04;
|
||||
char editorBuf[24];
|
||||
formatPlainFor(DeckParam::kAttackCurve, editorPlainValue(DeckParam::kAttackCurve, play),
|
||||
editorBuf, sizeof(editorBuf));
|
||||
CHECK(std::string(editorBuf) == "1.04");
|
||||
const double hostNorm =
|
||||
reasampler::instrument::ui::deckParamNorm(DeckParam::kAttackCurve, play);
|
||||
char hostBuf[24];
|
||||
formatPlainFor(DeckParam::kAttackCurve, toPlain(DeckParam::kAttackCurve, hostNorm), hostBuf,
|
||||
sizeof(hostBuf));
|
||||
CHECK(std::string(hostBuf) == "1.04");
|
||||
}
|
||||
|
||||
static void testKeyTrackPrintsTheSameDigitsFromEitherSurface() {
|
||||
using reasampler::instrument::map::InstrumentParams;
|
||||
for (int step = 0; step <= 40; ++step) {
|
||||
const double norm = step / 40.0;
|
||||
InstrumentParams params;
|
||||
params.keyTrack = reasampler::instrument::ui::keyTrackFromNorm(norm);
|
||||
const double editorNorm = reasampler::instrument::ui::keyTrackNormFrom(params.keyTrack);
|
||||
char hostBuf[24];
|
||||
char editorBuf[24];
|
||||
formatPlainFor(DeckParam::kKeyTrack, toPlain(DeckParam::kKeyTrack, norm), hostBuf,
|
||||
sizeof(hostBuf));
|
||||
formatPlainFor(DeckParam::kKeyTrack, toPlain(DeckParam::kKeyTrack, editorNorm), editorBuf,
|
||||
sizeof(editorBuf));
|
||||
if (std::strcmp(hostBuf, editorBuf) != 0) {
|
||||
std::printf("FAIL key-track at norm %.4f: host \"%s\" vs editor \"%s\"\n",
|
||||
norm, hostBuf, editorBuf);
|
||||
++g_fail;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void testMasterGainPrintsTheSameDigitsFromEitherSurface() {
|
||||
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();
|
||||
testAnOffDetentExponentReadsTrueToBothTheHostAndTheEditor();
|
||||
testMasterGainPrintsTheSameDigitsFromEitherSurface();
|
||||
testKeyTrackPrintsTheSameDigitsFromEitherSurface();
|
||||
testTypingBackADisplayedValueLandsOnIt();
|
||||
testAShortBufferIsNeverOverrunAndAlwaysTerminates();
|
||||
testEveryExposedParameterHasAFormatterThatWritesSomething();
|
||||
if (g_fail == 0) std::printf("param_format: all tests passed\n");
|
||||
return g_fail == 0 ? 0 : 1;
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
// 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 testEveryNumberedRowIsIssued() {
|
||||
// Key-track (pitch) and Trigger length were reserved-but-unissued while they routed through
|
||||
// the reload tier; both are note-on-latched now, so both are issued under the numbers that
|
||||
// were held for them and no other id moved — which is what the block-and-step scheme bought.
|
||||
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);
|
||||
// The whole table is issued today — 44 of 44.
|
||||
CHECK(exposedParams().size() == paramTable().size());
|
||||
CHECK(exposedParams().size() == 44);
|
||||
}
|
||||
|
||||
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();
|
||||
testEveryNumberedRowIsIssued();
|
||||
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 a host parameter write, both sides of the model/audio split. The
|
||||
// load-bearing one is the EQUIVALENCE assertion: patching a control into the live block must
|
||||
// produce exactly the block the model path would have folded after the same write — which is what
|
||||
// makes a second routing table safe.
|
||||
|
||||
#include "../src/core/instrument/param/param_live.h"
|
||||
|
||||
#include "../src/core/instrument/param/param_id.h"
|
||||
#include "../src/core/instrument/param/param_units.h"
|
||||
#include "../src/core/instrument/map/sample_map.h"
|
||||
#include "../src/core/instrument/ui/deck_values.h"
|
||||
#include "../src/core/util/curve_law.h"
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
|
||||
using namespace reasampler;
|
||||
using namespace reasampler::instrument::param;
|
||||
using reasampler::instrument::engine::LiveValues;
|
||||
using reasampler::instrument::engine::foldLive;
|
||||
using reasampler::instrument::map::InstrumentParams;
|
||||
using reasampler::instrument::map::PlaySeconds;
|
||||
using reasampler::instrument::map::resolvePlay;
|
||||
using reasampler::instrument::ui::DeckParam;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK_ID(cond, id) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d (param %u): %s\n", __LINE__, (id), #cond); ++g_fail; } } while(0)
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr int kRate = 48000;
|
||||
|
||||
// The MODEL path, verbatim: the write the editor makes, resolved and folded the way every
|
||||
// publisher folds it. This is the reference the patch is measured against.
|
||||
LiveValues modelBlock(const InstrumentParams& params) {
|
||||
return foldLive(resolvePlay(params.play, kRate), params.keyTrack);
|
||||
}
|
||||
|
||||
// A parameter set deliberately away from its defaults, so an equivalence that only holds at the
|
||||
// default cannot pass. Not every field — just enough that a mis-routed patch lands on a value
|
||||
// that differs from the one it should have written.
|
||||
InstrumentParams dialledParams() {
|
||||
InstrumentParams p;
|
||||
p.keyTrack = 1.5;
|
||||
p.play.adsr.attackSeconds = 0.31;
|
||||
p.play.adsr.holdSeconds = 0.07;
|
||||
p.play.adsr.decaySeconds = 0.44;
|
||||
p.play.adsr.sustainLevel = 0.62;
|
||||
p.play.adsr.releaseSeconds = 0.9;
|
||||
p.play.adsr.attackCurve = 2.5;
|
||||
p.play.trigger.lengthFraction = 0.4;
|
||||
p.play.trigAhd.attackSeconds = 0.12;
|
||||
p.play.trigAhd.holdFraction = 0.3;
|
||||
p.play.playRate = 1.2;
|
||||
p.play.pitchOffsetSemitones = -5.0;
|
||||
p.play.pitchEnv.enabled = true;
|
||||
p.play.pitchEnv.peakSemitones = 7.0;
|
||||
p.play.pitchEnv.shape.attackSeconds = 0.02;
|
||||
p.play.filter.enabled = true;
|
||||
p.play.filter.settings.cutoffNorm = 0.42f;
|
||||
p.play.filter.settings.resonanceNorm = 0.66f;
|
||||
p.play.filter.modAmount = -0.4;
|
||||
p.play.filter.velAmount = 0.25;
|
||||
p.play.filter.keyTrack = 0.75;
|
||||
p.play.filter.env.attackSeconds = 0.05;
|
||||
p.play.filter.trigEnv.decaySeconds = 0.6;
|
||||
return p;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// THE assertion this module exists for. For every exposed control and several normalized
|
||||
// positions: writing it through the MODEL side of a host write and folding must equal patching it
|
||||
// into the folded block. Whole-block, not per-field — a member the patch forgot to route is
|
||||
// caught as surely as one it routed to the wrong place. Compared through live_params' own
|
||||
// field-wise operator==, NOT a memcmp: the block carries padding no copy is required to preserve,
|
||||
// so a byte compare here was non-deterministic. Both sides are the HOST's paths, which is what
|
||||
// the shell actually calls; that the host's value map agrees with the editor's everywhere it
|
||||
// should is the separate assertion below.
|
||||
static void testPatchingAControlEqualsFoldingTheModelAfterTheSameWrite() {
|
||||
const double kPositions[] = {0.0, 0.137, 0.5, 0.813, 1.0};
|
||||
for (const ParamRow& row : exposedParams()) {
|
||||
// Master gain is not carried by the block at all — the processor's own atomic is its
|
||||
// route to the audio, and the patch reports that by refusing it.
|
||||
if (row.deck == DeckParam::kMasterGain) {
|
||||
LiveValues block = modelBlock(dialledParams());
|
||||
const LiveValues before = block;
|
||||
CHECK_ID(!applyLiveParam(block, row.deck, 0.25, kRate), row.id);
|
||||
CHECK_ID(before == block, row.id);
|
||||
continue;
|
||||
}
|
||||
for (double norm : kPositions) {
|
||||
InstrumentParams written = dialledParams();
|
||||
if (row.deck == DeckParam::kKeyTrack) {
|
||||
written.keyTrack = reasampler::instrument::ui::keyTrackFromNorm(norm);
|
||||
} else {
|
||||
CHECK_ID(writeHostParam(row.deck, written.play, norm), row.id);
|
||||
}
|
||||
const LiveValues expected = modelBlock(written);
|
||||
|
||||
LiveValues patched = modelBlock(dialledParams());
|
||||
CHECK_ID(applyLiveParam(patched, row.deck, norm, kRate), row.id);
|
||||
CHECK_ID(expected == patched, row.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A drawn contour makes Trigger length inert — the editor's knob goes dead and the fold pins the
|
||||
// fraction at 1.0. A host lane pointed at it must be equally inert, or automation would re-open a
|
||||
// control the model says is closed.
|
||||
static void testTriggerLengthIsInertUnderADrawnEnvelope() {
|
||||
InstrumentParams p = dialledParams();
|
||||
p.play.ampSpline.mode = reasampler::EnvMode::Spline;
|
||||
LiveValues block = modelBlock(p);
|
||||
CHECK(block.splineActive);
|
||||
CHECK(block.lengthFraction == 1.0);
|
||||
CHECK(applyLiveParam(block, DeckParam::kTrigLength, 0.2, kRate));
|
||||
CHECK(block.lengthFraction == 1.0);
|
||||
}
|
||||
|
||||
// A control with no parameter row is refused rather than silently landing somewhere.
|
||||
static void testAnUnexposedControlIsRefused() {
|
||||
LiveValues block = modelBlock(dialledParams());
|
||||
const LiveValues before = block;
|
||||
CHECK(!applyLiveParam(block, DeckParam::kPlayMode, 1.0, kRate));
|
||||
CHECK(!applyLiveParam(block, DeckParam::kVoiceCount, 1.0, kRate));
|
||||
CHECK(before == block);
|
||||
}
|
||||
|
||||
// Every exposed control resolves to a home the host's read and write paths actually reach. The
|
||||
// guard the promotion of pitch key-track needed: id 1000 was issued against a control whose value
|
||||
// is not in PlaySeconds, and nothing failed to compile.
|
||||
static void testEveryExposedControlHasAValueHome() {
|
||||
for (const ParamRow& row : exposedParams()) {
|
||||
CHECK_ID(valueHomeFor(row.deck) != ValueHome::None, row.id);
|
||||
}
|
||||
// And the instance-scalar set is exactly the two the shell branches on by name.
|
||||
CHECK(valueHomeFor(DeckParam::kMasterGain) == ValueHome::InstanceScalar);
|
||||
CHECK(valueHomeFor(DeckParam::kKeyTrack) == ValueHome::InstanceScalar);
|
||||
int instanceScalars = 0;
|
||||
for (const ParamRow& row : paramTable()) {
|
||||
if (valueHomeFor(row.deck) == ValueHome::InstanceScalar) ++instanceScalars;
|
||||
}
|
||||
CHECK(instanceScalars == 2);
|
||||
}
|
||||
|
||||
// The host's value map is the editor's EXCEPT on the twelve curve exponents, where it skips the
|
||||
// knob detent — a drag affordance a lane has no use for and which would otherwise flatten a
|
||||
// knot-drawn near-neutral exponent to exactly 1.0 on any lane pass. Both halves are asserted: the
|
||||
// agreement everywhere else, and the difference exactly inside the detent band.
|
||||
static void testTheHostSkipsTheCurveDetentAndNothingElse() {
|
||||
using reasampler::instrument::ui::deckParamUnit;
|
||||
using reasampler::instrument::ui::storedFromNorm;
|
||||
using reasampler::instrument::ui::UnitCategory;
|
||||
const double kPositions[] = {0.0, 0.137, 0.4, 0.495, 0.5, 0.505, 0.6, 0.813, 1.0};
|
||||
for (const ParamRow& row : exposedParams()) {
|
||||
const bool exponent = deckParamUnit(row.deck) == UnitCategory::Exponent;
|
||||
for (double norm : kPositions) {
|
||||
const double editor = storedFromNorm(row.deck, norm);
|
||||
const double host = hostStoredFromNorm(row.deck, norm);
|
||||
// Inside the band but off centre is the ONE place they may differ, and must.
|
||||
const bool inBand = exponent && norm != 0.5 &&
|
||||
norm > 0.5 - reasampler::util::kCurveKnobDetent &&
|
||||
norm < 0.5 + reasampler::util::kCurveKnobDetent;
|
||||
if (inBand) {
|
||||
CHECK_ID(editor == reasampler::util::kCurveNeutral, row.id);
|
||||
CHECK_ID(host != editor, row.id);
|
||||
} else {
|
||||
CHECK_ID(host == editor, row.id);
|
||||
}
|
||||
}
|
||||
// The identity stays reachable from the host side too — that is what makes skipping the
|
||||
// detent a value-preserving change rather than a lost reset.
|
||||
if (exponent) {
|
||||
CHECK_ID(hostStoredFromNorm(row.deck, 0.5) == reasampler::util::kCurveNeutral, row.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
testPatchingAControlEqualsFoldingTheModelAfterTheSameWrite();
|
||||
testTriggerLengthIsInertUnderADrawnEnvelope();
|
||||
testAnUnexposedControlIsRefused();
|
||||
testEveryExposedControlHasAValueHome();
|
||||
testTheHostSkipsTheCurveDetentAndNothingElse();
|
||||
if (g_fail == 0) std::printf("param_live: all tests passed\n");
|
||||
return g_fail == 0 ? 0 : 1;
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
// Standalone tests for the block-boundary merge — the AUTHORITY LIFETIME of a host automation
|
||||
// point. The load-bearing one is the RELEASE: a point outranks the model only until the model
|
||||
// carries it. Held forever, one point defeats every later state restore, bake reset and knob
|
||||
// move; released too eagerly, a lane in flight reverts for a block.
|
||||
|
||||
#include "../src/core/instrument/param/param_merge.h"
|
||||
|
||||
#include "../src/core/instrument/param/param_id.h"
|
||||
#include "../src/core/instrument/param/param_live.h"
|
||||
#include "../src/core/instrument/param/param_units.h"
|
||||
#include "../src/core/instrument/map/sample_map.h"
|
||||
#include "../src/core/instrument/ui/deck_values.h"
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
|
||||
using namespace reasampler;
|
||||
using namespace reasampler::instrument::param;
|
||||
using reasampler::instrument::engine::LiveValues;
|
||||
using reasampler::instrument::engine::foldLive;
|
||||
using reasampler::instrument::map::InstrumentParams;
|
||||
using reasampler::instrument::map::resolvePlay;
|
||||
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)
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr int kRate = 48000;
|
||||
constexpr std::size_t kCutoff = static_cast<std::size_t>(DeckParam::kFilterCutoff);
|
||||
|
||||
LiveValues modelBlock(const InstrumentParams& params) {
|
||||
return foldLive(resolvePlay(params.play, kRate), params.keyTrack);
|
||||
}
|
||||
|
||||
// A slot array with one lane driving `deck`.
|
||||
struct Slots {
|
||||
AutomationSlot s[kDeckParamSlots] = {};
|
||||
AutomationSlot* operator()() { return s; }
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
// THE test this module exists for. A point lands, the merge applies it over the model; the model
|
||||
// is then rewritten to something else while the hold is STILL outstanding, and the point must win
|
||||
// — that is the ≤one-tick window the hold is for. Once the fold has caught the model up and the
|
||||
// slot is marked folded, the merge releases it and the MODEL wins, permanently.
|
||||
static void testAHeldPointOutranksTheModelOnlyUntilTheModelCarriesIt() {
|
||||
InstrumentParams automated;
|
||||
automated.play.filter.settings.cutoffNorm = 0.9f;
|
||||
|
||||
// 1. The point is held: a model that says 0.9 loses to the lane's 0.2.
|
||||
Slots slots;
|
||||
slots.s[kCutoff] = AutomationSlot{0.2, /*held=*/true, /*folded=*/false};
|
||||
LiveValues block = modelBlock(automated);
|
||||
mergeAutomation(block, slots(), kDeckParamSlots, kRate);
|
||||
CHECK(block.filterSettings.cutoffNorm == 0.2f);
|
||||
CHECK(slots.s[kCutoff].held); // still outstanding — nothing has folded it
|
||||
|
||||
// 2. A state restore lands a different value while the hold is outstanding. Still the lane's:
|
||||
// this is the window the hold exists for, and it is the ONLY window.
|
||||
InstrumentParams restored;
|
||||
restored.play.filter.settings.cutoffNorm = 0.55f;
|
||||
block = modelBlock(restored);
|
||||
mergeAutomation(block, slots(), kDeckParamSlots, kRate);
|
||||
CHECK(block.filterSettings.cutoffNorm == 0.2f);
|
||||
|
||||
// 3. The UI folds the point into the model and republishes; the merge sees the release.
|
||||
InstrumentParams folded;
|
||||
folded.play.filter.settings.cutoffNorm = 0.2f;
|
||||
slots.s[kCutoff].folded = true;
|
||||
block = modelBlock(folded);
|
||||
mergeAutomation(block, slots(), kDeckParamSlots, kRate);
|
||||
CHECK(block.filterSettings.cutoffNorm == 0.2f);
|
||||
CHECK(!slots.s[kCutoff].held); // RELEASED — this is the whole fix
|
||||
|
||||
// 4. And now a later writer — a preset load, a bake reset, a knob — actually reaches the
|
||||
// audio. This is what a latch with no release makes impossible.
|
||||
block = modelBlock(restored);
|
||||
mergeAutomation(block, slots(), kDeckParamSlots, kRate);
|
||||
CHECK(block.filterSettings.cutoffNorm == 0.55f);
|
||||
}
|
||||
|
||||
// The release must not leak across points: a lane that sent a NEW point after the fold read the
|
||||
// previous one is still driving, and its new value must survive the release of the old.
|
||||
static void testANewPointAfterTheFoldIsNotReleasedByIt() {
|
||||
Slots slots;
|
||||
// The audio thread re-holds at 0.7; the UI's fold was of the earlier 0.2, so the sequence
|
||||
// comparison the shell runs leaves `folded` false for this newer point.
|
||||
slots.s[kCutoff] = AutomationSlot{0.7, /*held=*/true, /*folded=*/false};
|
||||
InstrumentParams foldedModel;
|
||||
foldedModel.play.filter.settings.cutoffNorm = 0.2f;
|
||||
LiveValues block = modelBlock(foldedModel);
|
||||
mergeAutomation(block, slots(), kDeckParamSlots, kRate);
|
||||
CHECK(block.filterSettings.cutoffNorm == 0.7f);
|
||||
CHECK(slots.s[kCutoff].held);
|
||||
}
|
||||
|
||||
// A slot that never took a point leaves the block exactly as the model folded it.
|
||||
static void testAnUnheldSlotLeavesTheBlockAlone() {
|
||||
Slots slots;
|
||||
InstrumentParams p;
|
||||
p.play.adsr.attackSeconds = 0.25;
|
||||
const LiveValues expected = modelBlock(p);
|
||||
LiveValues block = modelBlock(p);
|
||||
mergeAutomation(block, slots(), kDeckParamSlots, kRate);
|
||||
CHECK(expected == block);
|
||||
}
|
||||
|
||||
// The dirty gate at its source. A lane resending the value it already sent — the steady state of
|
||||
// a flat segment in read mode — must report that nothing moved, so the block is never re-read,
|
||||
// re-merged or republished. A lane that MOVED must report that it did, and so must a repeat that
|
||||
// arrives after the hold was released (some other writer may have moved the model since).
|
||||
static void testARepeatOfAStandingHoldMovesNothing() {
|
||||
AutomationSlot slot{0.4, /*held=*/true, /*folded=*/false};
|
||||
CHECK(!automationPointMoves(slot, 0.4));
|
||||
CHECK(automationPointMoves(slot, 0.41));
|
||||
slot.held = false;
|
||||
CHECK(automationPointMoves(slot, 0.4));
|
||||
}
|
||||
|
||||
// The gate the merge publishes through. Two blocks folded from the same parameter set and merged
|
||||
// with the same slot state compare EQUAL — which a byte compare does not reliably report, since
|
||||
// LiveValues carries padding no copy is required to preserve. This is the assertion that fails if
|
||||
// operator== is ever "simplified" back into a memcmp.
|
||||
static void testTwoIdenticalMergesCompareEqual() {
|
||||
InstrumentParams p;
|
||||
p.play.adsr.attackSeconds = 0.13;
|
||||
p.play.trigger.lengthFraction = 0.6;
|
||||
Slots slots;
|
||||
slots.s[kCutoff] = AutomationSlot{0.4, /*held=*/true, /*folded=*/false};
|
||||
|
||||
LiveValues first = modelBlock(p);
|
||||
mergeAutomation(first, slots(), kDeckParamSlots, kRate);
|
||||
LiveValues again = modelBlock(p);
|
||||
mergeAutomation(again, slots(), kDeckParamSlots, kRate);
|
||||
CHECK(first == again);
|
||||
|
||||
slots.s[kCutoff].norm = 0.41;
|
||||
LiveValues moved = modelBlock(p);
|
||||
mergeAutomation(moved, slots(), kDeckParamSlots, kRate);
|
||||
CHECK(first != moved);
|
||||
}
|
||||
|
||||
// The merge addresses a slot by DeckParam ORDINAL, which is the one thing it does that
|
||||
// param_live's equivalence test cannot see: a slot recovered as the wrong enumerator would patch
|
||||
// a neighbouring control. Swept over every exposed control for that reason, not to re-assert the
|
||||
// value laws param_live already owns.
|
||||
static void testEverySlotResolvesToItsOwnControl() {
|
||||
const double kPositions[] = {0.0, 0.29, 0.5, 0.77, 1.0};
|
||||
for (const ParamRow& row : exposedParams()) {
|
||||
if (row.deck == DeckParam::kMasterGain) continue; // reaches the audio beside the block
|
||||
for (double norm : kPositions) {
|
||||
InstrumentParams written;
|
||||
if (row.deck == DeckParam::kKeyTrack) {
|
||||
written.keyTrack = reasampler::instrument::ui::keyTrackFromNorm(norm);
|
||||
} else {
|
||||
writeHostParam(row.deck, written.play, norm);
|
||||
}
|
||||
const LiveValues expected = modelBlock(written);
|
||||
|
||||
Slots slots;
|
||||
slots.s[static_cast<std::size_t>(row.deck)] =
|
||||
AutomationSlot{norm, /*held=*/true, /*folded=*/false};
|
||||
LiveValues merged = modelBlock(InstrumentParams{});
|
||||
mergeAutomation(merged, slots(), kDeckParamSlots, kRate);
|
||||
CHECK_ID(expected == merged, row.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
testAHeldPointOutranksTheModelOnlyUntilTheModelCarriesIt();
|
||||
testANewPointAfterTheFoldIsNotReleasedByIt();
|
||||
testAnUnheldSlotLeavesTheBlockAlone();
|
||||
testARepeatOfAStandingHoldMovesNothing();
|
||||
testTwoIdenticalMergesCompareEqual();
|
||||
testEverySlotResolvesToItsOwnControl();
|
||||
if (g_fail == 0) std::printf("param_merge: all tests passed\n");
|
||||
return g_fail == 0 ? 0 : 1;
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
// 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/engine/master_gain.h"
|
||||
#include "../src/core/instrument/map/play_seconds.h"
|
||||
#include "../src/core/instrument/map/sample_map.h"
|
||||
#include "../src/core/instrument/ui/deck_values.h"
|
||||
|
||||
#include <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[] = {
|
||||
{kParamKeyTrackPitch, "%", 0.0, 200.0},
|
||||
{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},
|
||||
{kParamTriggerLength, "%", 0.0, 100.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 testTheHostAndTheEditorAgreeOnEveryDefaultPosition() {
|
||||
// The criterion is that a host's reset-to-default and the editor's double-click land on the
|
||||
// SAME value — and those are different code paths: ParameterInfo::defaultNormalizedValue
|
||||
// comes from defaultNormalized (a per-CATEGORY switch), the editor's needle from
|
||||
// deckParamNorm (a per-ID one). Asserting the param module against itself would not see the
|
||||
// two disagree, and they carry three independently written full scales to disagree about.
|
||||
using reasampler::instrument::ui::deckParamNorm;
|
||||
const PlaySeconds defaults;
|
||||
for (const ParamRow& row : exposedParams()) {
|
||||
const double hostNorm = defaultNormalized(row.deck);
|
||||
// The four that STORE their normalized position take it verbatim on both surfaces, and
|
||||
// the two instance scalars are not in PlaySeconds at all — each read where it lives.
|
||||
double editorNorm = 0.0;
|
||||
switch (valueHomeFor(row.deck)) {
|
||||
case ValueHome::InstanceScalar:
|
||||
editorNorm = (row.deck == DeckParam::kMasterGain)
|
||||
? reasampler::instrument::engine::masterGainNormFromLinear(1.0) // unity
|
||||
: reasampler::instrument::ui::keyTrackNormFrom(
|
||||
reasampler::instrument::map::InstrumentParams{}.keyTrack);
|
||||
break;
|
||||
case ValueHome::ParamSetNorm:
|
||||
case ValueHome::ParamSet:
|
||||
editorNorm = deckParamNorm(row.deck, defaults);
|
||||
break;
|
||||
case ValueHome::None:
|
||||
CHECK_ID(false, row.id); // an exposed control with no home reads nothing
|
||||
continue;
|
||||
}
|
||||
CHECK_ID(hostNorm == editorNorm, row.id);
|
||||
}
|
||||
}
|
||||
|
||||
// The criterion names the editor's DOUBLE-CLICK, and that gesture is resetDeckParam, not
|
||||
// deckParamNorm over a default-constructed set. Asserted directly: reset a DIALLED set and its
|
||||
// stored field must read back at exactly the normalized value the host resets to. (The two
|
||||
// instance scalars have no resetDeckParam entry — the shell resets those from InstrumentParams,
|
||||
// which the test above covers at the same position.)
|
||||
static void testADoubleClickResetLandsOnTheHostsDefaultNormalized() {
|
||||
using reasampler::instrument::ui::deckParamNorm;
|
||||
using reasampler::instrument::ui::resetDeckParam;
|
||||
using reasampler::instrument::ui::setDeckParam;
|
||||
for (const ParamRow& row : exposedParams()) {
|
||||
if (valueHomeFor(row.deck) == ValueHome::InstanceScalar) continue;
|
||||
PlaySeconds dialled;
|
||||
// Away from the default first, so a reset that did nothing at all cannot pass.
|
||||
setDeckParam(row.deck, dialled, 0.37, /*segment=*/0);
|
||||
CHECK_ID(deckParamNorm(row.deck, dialled) != defaultNormalized(row.deck) ||
|
||||
defaultNormalized(row.deck) == 0.37,
|
||||
row.id);
|
||||
resetDeckParam(row.deck, dialled);
|
||||
CHECK_ID(deckParamNorm(row.deck, dialled) == defaultNormalized(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();
|
||||
testTheHostAndTheEditorAgreeOnEveryDefaultPosition();
|
||||
testADoubleClickResetLandsOnTheHostsDefaultNormalized();
|
||||
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