Close pass four: undetented host curve read, LiveValues guard, ordering assert, static-lane fix, docs
Points toPlain's exponent arm at the undetented curve map so host reads match the editor; adds a sizeof guard plus field-poison test for LiveValues::operator==; skips the model write when an automation value hasn't moved; corrects five stale doc citations.
This commit is contained in:
+2
-2
@@ -1321,8 +1321,8 @@ value semantics, any deck geometry, or the bake's reset *membership* (W3-T2's).
|
||||
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`
|
||||
§7 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
|
||||
§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
|
||||
|
||||
@@ -335,6 +335,12 @@ 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.
|
||||
|
||||
**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
|
||||
|
||||
@@ -70,6 +70,13 @@ struct LiveValues {
|
||||
// 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");
|
||||
// Guards operator== against silent staleness: a member added to the struct above changes this
|
||||
// size, so the assert fails at the new member's own commit instead of leaving a live control
|
||||
// that never reaches a sounding voice with no compiler or test signal. 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
|
||||
|
||||
@@ -88,21 +88,16 @@ no longer exist.
|
||||
- **Round-trip exactness at arbitrary values is NOT a property here and must not be asserted.**
|
||||
No log map satisfies `toNormalized(toPlain(n)) == n` in double, and demanding it would rule
|
||||
out the taper the range needs. Exactness is required at the defaults; monotonicity everywhere.
|
||||
- **A curve exponent inside the knob detent but not exactly neutral READS BACK as `1.00` on the
|
||||
host, while the stored value keeps its true exponent.** The detent lives in `curve_law`'s
|
||||
norm↔exponent map, and `toPlain` is that map — so an off-detent near-neutral exponent (an
|
||||
overlay knot drag can set one) displays as `1.00` in the host. The editor's own label reads the
|
||||
stored field and shows the true value.
|
||||
**The WRITE path does NOT have this loss, and that is deliberate.** A host write goes through
|
||||
`hostStoredFromNorm`, which skips the detent: the detent is a DRAG affordance — a drag grid
|
||||
- **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 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, and applying it
|
||||
would flatten a knot-drawn exponent to `1.0` on any lane pass. `test_param_live`'s
|
||||
`testTheHostSkipsTheCurveDetentAndNothingElse` pins both halves: the host map is the editor's
|
||||
everywhere else, and differs exactly inside the band.
|
||||
**What remains is the DISPLAY divergence above**, which this module already documents as
|
||||
structural and which no change to the frozen `toPlain`/`toNormalized` pair was made to chase.
|
||||
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
|
||||
|
||||
@@ -40,9 +40,10 @@ void mergeAutomation(engine::LiveValues& block, AutomationSlot* slots, std::size
|
||||
// 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::refreshLive` 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.
|
||||
// `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;
|
||||
}
|
||||
|
||||
@@ -191,7 +191,9 @@ double toPlain(DeckParam deck, double normalized) {
|
||||
return ui::rateRatioFromNorm(normalized, ui::kRateMinRatio, ui::kRateMaxRatio) *
|
||||
kPercentFullScale;
|
||||
case UnitKind::Dimensionless:
|
||||
return util::curveFromKnobNorm(normalized);
|
||||
// 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
|
||||
|
||||
@@ -126,14 +126,18 @@ the single authority.** Everything else that holds these values is a cache or a
|
||||
| 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 |
|
||||
| Reload seed (`reloadInstrument`) | under `reloadMutex_` | the publish (it re-folds the model) |
|
||||
| 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 except the last writes the model directly, so for those "authority ends" is just
|
||||
"the write happened". The automation lane is the only one that cannot: 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.
|
||||
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:192`
|
||||
— 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
|
||||
@@ -171,14 +175,15 @@ under it: the automation fold, the host's generic panel, a state restore.
|
||||
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::refreshLive` runs
|
||||
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 the bake's 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.
|
||||
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
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
// 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 — refreshReleases()
|
||||
// only runs inside process()'s merge branch (reasampler_processor.cpp), and publishLiveParams
|
||||
// always bumps the generation that branch checks, so the next block is guaranteed to take it.
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -44,10 +47,13 @@ public:
|
||||
return ridesTheBlock;
|
||||
}
|
||||
|
||||
// Refreshes each held slot's release answer. 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.
|
||||
// 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,
|
||||
// because publishLiveParams always bumps the generation that branch checks, so the next block
|
||||
// is guaranteed to run this. 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;
|
||||
|
||||
@@ -211,10 +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 rather than re-seeding off
|
||||
// it on the next tick. Same reason at commitLive.
|
||||
seenParamsGeneration_ = processor_->instrumentParamsGeneration();
|
||||
// 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.
|
||||
@@ -227,8 +228,7 @@ 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_->instrumentParamsGeneration();
|
||||
seenParamsGeneration_ = processor_->setInstrumentParams(params_);
|
||||
processor_->publishLiveParams();
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
|
||||
#include <cassert>
|
||||
|
||||
#include "base/source/fstring.h"
|
||||
#include "pluginterfaces/base/ustring.h"
|
||||
#include "pluginterfaces/vst/ivstparameterchanges.h" // IParameterChanges / IParamValueQueue
|
||||
@@ -291,16 +293,23 @@ void ReaSamplerProcessor::drainAutomationToModel() {
|
||||
++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) {
|
||||
writeDeckParamToModel(params, row.deck, value);
|
||||
moved = true;
|
||||
}
|
||||
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.
|
||||
// The release below still fires: the model already carries the point either way.
|
||||
if (value == modelParamNormalized(params, row.deck)) 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;
|
||||
// Captured before the publish below, so the assert at the release loop can tell "the model's
|
||||
// publish already landed" from "it merely happened to be in flight for some other reason".
|
||||
const std::uint32_t generationBeforeFold = liveParams_.generation();
|
||||
if (moved) {
|
||||
setInstrumentParams(params);
|
||||
publishLiveParams();
|
||||
@@ -311,6 +320,13 @@ void ReaSamplerProcessor::drainAutomationToModel() {
|
||||
// model carries the point. Released any earlier and the audio thread could drop the hold
|
||||
// ahead of the block that carries its value; never released at all — the defect this
|
||||
// replaces — and one point would defeat every later restore, reset and knob move.
|
||||
// Enforced, not just commented: two prior passes inverted this order and every test in the
|
||||
// tree still passed, because nothing exercises `process()`. `publishLiveParams` is a no-op
|
||||
// before the engine has a sample rate (`builtSampleRate_`), which is the one case this assert
|
||||
// must not fire for.
|
||||
assert((!moved || builtSampleRate_.load(std::memory_order_relaxed) <= 0 ||
|
||||
liveParams_.generation() != generationBeforeFold) &&
|
||||
"release ran ahead of the publish that gives it authority");
|
||||
for (std::size_t i = 0; i < foldedCount; ++i) {
|
||||
automation_.release(foldedSlots[i], foldedSeqs[i]);
|
||||
}
|
||||
|
||||
@@ -179,11 +179,12 @@ InstrumentParams ReaSamplerProcessor::instrumentParams(std::uint32_t& generation
|
||||
return params_;
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::setInstrumentParams(const InstrumentParams& 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()) {
|
||||
@@ -191,8 +192,10 @@ void ReaSamplerProcessor::setInstrumentParams(const InstrumentParams& params) {
|
||||
}
|
||||
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.
|
||||
++paramsGeneration_;
|
||||
// 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
|
||||
@@ -214,6 +217,7 @@ void ReaSamplerProcessor::setInstrumentParams(const InstrumentParams& params) {
|
||||
// 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() {
|
||||
|
||||
@@ -190,7 +190,7 @@ 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
|
||||
@@ -392,9 +392,9 @@ private:
|
||||
// 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 (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.
|
||||
// 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.
|
||||
|
||||
@@ -91,6 +91,68 @@ static void testADrawnEnvelopePinsTheFoldedTriggerLength() {
|
||||
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() {
|
||||
LiveParams block;
|
||||
LiveValues out;
|
||||
@@ -188,6 +250,7 @@ static void testRampStepIsRateDerived() {
|
||||
int main() {
|
||||
testFoldCarriesEveryContinuousControl();
|
||||
testADrawnEnvelopePinsTheFoldedTriggerLength();
|
||||
testEveryFieldOfLiveValuesIsCompared();
|
||||
testUnpublishedBlockReadsAsNothing();
|
||||
testConcurrentReaderNeverSeesAHalfAppliedEdit();
|
||||
testRampTerminatesExactlyOnTheTarget();
|
||||
|
||||
@@ -126,11 +126,10 @@ static void testTheEditorAndTheHostPrintTheSameDigitsAtTheSameStoredValue() {
|
||||
}
|
||||
}
|
||||
|
||||
// The one place the two surfaces GENUINELY diverge, asserted so it stays a known property rather
|
||||
// than a surprise: an exponent inside curve_law's centre detent but not exactly neutral is
|
||||
// reachable only through an overlay knot drag, and the host — which holds the norm and nothing
|
||||
// else — reads it back as the neutral the knob law snaps to.
|
||||
static void testAnOffDetentExponentReadsNeutralToTheHostAndTrueToTheEditor() {
|
||||
// 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.
|
||||
@@ -144,7 +143,7 @@ static void testAnOffDetentExponentReadsNeutralToTheHostAndTrueToTheEditor() {
|
||||
char hostBuf[24];
|
||||
formatPlainFor(DeckParam::kAttackCurve, toPlain(DeckParam::kAttackCurve, hostNorm), hostBuf,
|
||||
sizeof(hostBuf));
|
||||
CHECK(std::string(hostBuf) == "1.00");
|
||||
CHECK(std::string(hostBuf) == "1.04");
|
||||
}
|
||||
|
||||
static void testKeyTrackPrintsTheSameDigitsFromEitherSurface() {
|
||||
@@ -227,7 +226,7 @@ static void testEveryExposedParameterHasAFormatterThatWritesSomething() {
|
||||
int main() {
|
||||
testEachCategoryPrintsItsSpecifiedShape();
|
||||
testTheEditorAndTheHostPrintTheSameDigitsAtTheSameStoredValue();
|
||||
testAnOffDetentExponentReadsNeutralToTheHostAndTrueToTheEditor();
|
||||
testAnOffDetentExponentReadsTrueToBothTheHostAndTheEditor();
|
||||
testMasterGainPrintsTheSameDigitsFromEitherSurface();
|
||||
testKeyTrackPrintsTheSameDigitsFromEitherSurface();
|
||||
testTypingBackADisplayedValueLandsOnIt();
|
||||
|
||||
Reference in New Issue
Block a user