diff --git a/docs/PLAN.md b/docs/PLAN.md index 03fc2fd..af9997a 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -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 diff --git a/docs/product/parameter-automation.md b/docs/product/parameter-automation.md index 1d75518..6ce5c36 100644 --- a/docs/product/parameter-automation.md +++ b/docs/product/parameter-automation.md @@ -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 diff --git a/src/core/instrument/engine/live_params.h b/src/core/instrument/engine/live_params.h index 2afd1d1..b8feebb 100644 --- a/src/core/instrument/engine/live_params.h +++ b/src/core/instrument/engine/live_params.h @@ -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, "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`, 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 diff --git a/src/core/instrument/param/CLAUDE.md b/src/core/instrument/param/CLAUDE.md index cf20aad..729b3ae 100644 --- a/src/core/instrument/param/CLAUDE.md +++ b/src/core/instrument/param/CLAUDE.md @@ -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 diff --git a/src/core/instrument/param/param_merge.h b/src/core/instrument/param/param_merge.h index 004ad91..455eff3 100644 --- a/src/core/instrument/param/param_merge.h +++ b/src/core/instrument/param/param_merge.h @@ -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; } diff --git a/src/core/instrument/param/param_units.cpp b/src/core/instrument/param/param_units.cpp index 2b358ba..a05cd6b 100644 --- a/src/core/instrument/param/param_units.cpp +++ b/src/core/instrument/param/param_units.cpp @@ -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 diff --git a/src/shell/instrument/CLAUDE.md b/src/shell/instrument/CLAUDE.md index 771cd83..020e0f9 100644 --- a/src/shell/instrument/CLAUDE.md +++ b/src/shell/instrument/CLAUDE.md @@ -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 diff --git a/src/shell/instrument/automation_channel.h b/src/shell/instrument/automation_channel.h index 26d8b9c..06ea8a8 100644 --- a/src/shell/instrument/automation_channel.h +++ b/src/shell/instrument/automation_channel.h @@ -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; diff --git a/src/shell/instrument/editor_session.cpp b/src/shell/instrument/editor_session.cpp index 0558f13..6f02157 100644 --- a/src/shell/instrument/editor_session.cpp +++ b/src/shell/instrument/editor_session.cpp @@ -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(); } diff --git a/src/shell/instrument/instrument_params.cpp b/src/shell/instrument/instrument_params.cpp index 79f223d..619f268 100644 --- a/src/shell/instrument/instrument_params.cpp +++ b/src/shell/instrument/instrument_params.cpp @@ -6,6 +6,8 @@ #include "shell/instrument/reasampler_processor.h" +#include + #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]); } diff --git a/src/shell/instrument/processor_state.cpp b/src/shell/instrument/processor_state.cpp index bf400d1..6dacac4 100644 --- a/src/shell/instrument/processor_state.cpp +++ b/src/shell/instrument/processor_state.cpp @@ -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 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() { diff --git a/src/shell/instrument/reasampler_processor.h b/src/shell/instrument/reasampler_processor.h index 90310e5..e3b9cc6 100644 --- a/src/shell/instrument/reasampler_processor.h +++ b/src/shell/instrument/reasampler_processor.h @@ -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. diff --git a/tests/test_live_params.cpp b/tests/test_live_params.cpp index 5bf556d..8ab0379 100644 --- a/tests/test_live_params.cpp +++ b/tests/test_live_params.cpp @@ -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(); diff --git a/tests/test_param_format.cpp b/tests/test_param_format.cpp index 7748ed6..aac4a3b 100644 --- a/tests/test_param_format.cpp +++ b/tests/test_param_format.cpp @@ -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();