instrument: latch the note done at the read-head run-off, fit the migrated fades, and lift the curve dial and overlay selection into pure modules

This commit is contained in:
2026-07-31 09:17:04 -04:00
parent 13e8c5c4d9
commit d60ab1524a
18 changed files with 575 additions and 129 deletions
+1 -1
View File
@@ -283,7 +283,7 @@ anything for a trigger shape.
- `param_slider` — parameter control-panel: vertical stack of TOGGLE (two-segment selector) and SLIDER (horizontal track) rows; maps normalized value to/from handle pixel.
- `embed_strip` — compact single-row control layout for embed mode in the track FX chain.
- `knob_deck` — pure knob-deck layout + hit-test (FB1): group-box / caption-row / compact-toggle / knob-cell geometry, deterministic whole-group wrap, `DeckLayout` / `DeckHit`. Mirror of `action_bar`/`param_slider`; no LICE or REAPER types.
- `deck_groups` — also home to `isLiveDeckParam` and `liveCommitFor`, the editor's whole commit-tier routing decision (see "Live parameter delivery" above); WHICH groups the Sample face's deck carries, split from `knob_deck`'s HOW they lay out: the `DeckParam` control-id space (the editor's `ParamControl` is an alias of it), the `DeckGroupId` list, `sampleDeckGroups` in signal-flow order (**pitch → filter → amp**, then voice/master), and the deck's bipolar-knob law. Reads `PlayMode` for the AMP group's Gate/Trigger face, which is why this and not `knob_deck` is the module that touches the engine's value layer.
- `deck_groups` — also home to `isLiveDeckParam` and `liveCommitFor`, the editor's whole commit-tier routing decision (see "Live parameter delivery" above), and to `OverlayEnv` + `nextOverlaySelection`/`overlayEnvInert`, the whole overlay-selection state machine (exclusivity, the none resting state, and which selections a disabled group makes inert); WHICH groups the Sample face's deck carries, split from `knob_deck`'s HOW they lay out: the `DeckParam` control-id space (the editor's `ParamControl` is an alias of it), the `DeckGroupId` list, `sampleDeckGroups` in signal-flow order (**pitch → filter → amp**, then voice/master), and the deck's bipolar-knob law. Reads `PlayMode` for the AMP group's Gate/Trigger face, which is why this and not `knob_deck` is the module that touches the engine's value layer.
- `curve_popup` — pure curve-popup geometry + dismissal test (FB1): centered sheet over the Sample face — width/height clamps, title row, Close button rect, curve-box rect, outside-sheet dismissal test. Mirror of `overflow_menu`; no LICE or REAPER types.
- `envelope_overlay` — pure staged-envelope→polyline geometry for the Sample-view overlay (read from `envelope_overlay.h`): maps a `StageEnvelope` to a polyline inside a rect under whichever of TWO layout policies its `EnvKind` selects — an AHDSR draws a bounded param-domain schematic with its release RIGHT-ANCHORED to the canvas edge, an AHD draws 1:1 over the waveform's own time axis — plus a round mid-segment knot on every sloped stage that has a duration. Every vertex clamped in-canvas. Shares the `EnvNode`/`StageEnvelope`/`timeToX`/`levelToY` vocabulary with `envelope_edit` so the drawn handle and its grab region agree pixel-for-pixel. No VST3/REAPER/LICE types at the boundary.
- `envelope_edit` — pure node hit-test + pixel-delta→clamped-param inverse map for the draggable envelope nodes and their curve knots (read from `envelope_edit.h`): `nodeAtPoint` resolves a grab to the nearest node within a pick radius (Chebyshev distance, draw-order tie-break, knots appended last so a coincident endpoint handle wins); `resolveNodeDrag` maps a pixel delta since grab to a new `StageEnvelope` under the same caller-supplied per-param clamp bounds the knobs use — a drag can never produce a param a knob couldn't. Mirror of `card_drag`/`waveform_view`; the inverse of `envelope_overlay`'s params→polyline forward map, so node-drag, knot-drag and knob-edit read/write one shared model and can never diverge.
+10 -2
View File
@@ -90,8 +90,16 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
}
// The pitch AHD's Hold fraction is taken against the whole playable span, so its three
// stages lay 1:1 over the waveform from the start point.
pitchEnv_.configure(postStart, p.pitchEnv);
// stages lay 1:1 over the waveform from the start point. postStart is a SOURCE-frame count
// and this envelope counts OUTPUT frames (envelopes.h), so Varispeed — which consumes
// baseRatio_ source frames per output frame — needs the span converted, or a transposed
// note's envelope outruns (or outlives) the note it shapes. Preserve reads at the source
// rate, so its two domains already coincide.
const double pitchSpan =
(pitchEngine_ == PitchEngine::Preserve || !(baseRatio_ > 0.0))
? static_cast<double>(postStart)
: static_cast<double>(postStart) / baseRatio_;
pitchEnv_.configure(static_cast<std::int64_t>(pitchSpan + 0.5), p.pitchEnv);
pitchEnv_.noteOn();
// A restart lands every live glide back on the new note's own values, at a step derived
+11 -2
View File
@@ -348,6 +348,12 @@ private:
// play span ends within the ramp). With no declick (the common case) this is
// byte-identical to the plain idle-out.
if (triggerRanOff || readPos_ >= static_cast<double>(frameCount)) {
// The NOTE is over the moment the read head leaves its span, whether or not a ramp
// still rings: no later frame can carry envelope output. Latching here is what keeps
// a ringing-out voice out of soundingNote() — the Preserve cap would otherwise
// refuse a new onset, and mono legato would retune a voice already past its end
// (silencing the new note) for the whole ~4 ms ramp.
amplitudeDone_ = true;
if (declickPending_) seedDeclick();
if (!declickActive_) seedTerminalDeclick();
if (declickActive_) {
@@ -376,8 +382,11 @@ private:
// Peer of the read-head exhaustion path above: a Trigger AHD whose stages end BEFORE
// the play span (a zero decay, which the shape deliberately keeps expressible) cuts the
// same synthetic Preserve tail at whatever level it was at. Seeded from lastOut, which
// still holds the PREVIOUS frame — this one is already silent. Gate is left out on
// purpose: its amplitude reaches zero through a release, so there is no cut to ring out.
// still holds the PREVIOUS frame — this one is already silent. Gate is left out of THIS
// site only: its amplitude reaches zero through a release, so nothing here is cut
// mid-level. The exhaustion path above deliberately does NOT exclude Gate — a held Gate
// note whose source runs out with no loop is cut at its sustain level, and under
// Preserve that cut lands on the same recycled synthetic tail.
if (amplitudeDone_ && amp == 0.0 && !declickActive_ &&
playMode_ == PlayMode::Trigger) {
seedTerminalDeclick();
@@ -91,8 +91,26 @@ namespace reasampler::instrument::map {
// seconds at the project rate the reader is handed. v10 writes ZERO into both — the values
// live in the AHD now, so a DOWNGRADE to a pre-v10 binary loses the Trigger amp shape.
//
// LOSSY UNDER A RATE MISMATCH. The fades were SOURCE frames and the AHD stores wall-clock
// seconds, so the lift divides by the PROJECT rate while the build later multiplies by the
// DECODE rate: a file whose own rate differs from the project's comes back scaled by that
// ratio (a 441-frame fade on a 44.1 kHz file in a 48 kHz project resolves to 405 source
// frames, ~8% short). The codec cannot close this — it never sees the file — and deferring the
// lift to build time would mean carrying the retired fade pair through the parameter set,
// reintroducing the mechanism the AHD replaced.
//
// A truncated/unknown/empty payload yields the DEFAULT parameter set.
// The exponents the lifted fades take. The AHD's curve law is phi^p (core/util/curve_law.h),
// which cannot reproduce the retired pair's equal-power sin/cos exactly at ANY exponent — so
// the lift takes the MINIMAX fit rather than the linear neutral, which is free (one constant,
// written once here) and several times closer. The two differ because the two stages fit
// different forms: attack fits phi^p to sin(pi*phi/2), decay fits 1 - t^q to cos(pi*t/2).
// The measured bounds are asserted in tests/test_component_state_io.cpp. Every OTHER curve on
// a migrated blob still lifts to the neutral — only the fades had a prior shape to reproduce.
inline constexpr double kTriggerFadeLiftAttackCurve = 0.6133;
inline constexpr double kTriggerFadeLiftDecayCurve = 1.7437;
inline constexpr std::uint32_t kPerformanceStateVersion = 2;
// The params-payload format version and its detection marker. The marker is a high sentinel
+5 -3
View File
@@ -61,15 +61,17 @@ void putAhd(std::vector<std::uint8_t>& out, const AhdSeconds& a) {
}
// THE lift of the retired Trigger fade pair onto the AHD that replaced it: Attack takes the
// fade-in, Decay the fade-out, Hold the whole remainder — so a zero fade-out lands Decay = 0
// and the abrupt end an old instance could express stays representable. The fades were SOURCE
// frames and the AHD stores wall-clock seconds, so the conversion goes through the same
// project rate the v3 lift already uses. A v10-or-newer blob overwrites this from its own tail.
// and the abrupt end an old instance could express stays representable. The seconds conversion
// and its rate-mismatch bound, and the two fitted exponents, are documented in
// component_state_io.h. A v10-or-newer blob overwrites all five fields from its own tail.
void liftTriggerFades(std::int64_t fadeInFrames, std::int64_t fadeOutFrames, double projectRate,
AhdSeconds& out) {
const double rate = projectRate > 0.0 ? projectRate : 1.0;
out.attackSeconds = static_cast<double>(fadeInFrames > 0 ? fadeInFrames : 0) / rate;
out.decaySeconds = static_cast<double>(fadeOutFrames > 0 ? fadeOutFrames : 0) / rate;
out.holdFraction = 1.0;
out.attackCurve = kTriggerFadeLiftAttackCurve;
out.decayCurve = kTriggerFadeLiftDecayCurve;
}
// Read the play tail (v5 shape onward) into `p`. Shared by the legacy zone reader and the
+26
View File
@@ -191,6 +191,32 @@ bool isLiveDeckParam(DeckParam id) {
return false; // unreachable for a valid enumerator; silences a warning.
}
OverlayEnv overlayEnvForRadio(int radioId) {
switch (static_cast<DeckParam>(radioId)) {
case DeckParam::kAmpEnvSelect: return OverlayEnv::kAmp;
case DeckParam::kPitchEnvSelect: return OverlayEnv::kPitch;
case DeckParam::kFilterEnvSelect: return OverlayEnv::kFilter;
default: return OverlayEnv::kNone;
}
}
OverlayEnv nextOverlaySelection(OverlayEnv current, int radioId) {
const OverlayEnv picked = overlayEnvForRadio(radioId);
if (picked == OverlayEnv::kNone) return current; // not a radio: nothing selects
return (current == picked) ? OverlayEnv::kNone : picked;
}
bool overlayEnvInert(OverlayEnv env, bool pitchEnvEnabled, bool filterEnabled) {
switch (env) {
case OverlayEnv::kPitch: return !pitchEnvEnabled;
case OverlayEnv::kFilter: return !filterEnabled;
case OverlayEnv::kAmp:
case OverlayEnv::kNone:
return false;
}
return false; // unreachable for a valid enumerator; silences a warning.
}
bool liveCommitFor(LiveDragKind kind, int paramId) {
switch (kind) {
case LiveDragKind::kDeckKnob:
+19
View File
@@ -133,6 +133,25 @@ enum class LiveDragKind { kOther, kDeckKnob, kEnvNode };
// can reach — AHDSR or AHD, on any of the three envelopes — is itself live.
bool liveCommitFor(LiveDragKind kind, int paramId);
// Which envelope the waveform overlay draws and edits. Exclusive across the three envelope
// decks, and kNone is a valid resting state — the editor opens there. Transient view state:
// never persisted, never a parameter.
enum class OverlayEnv { kNone, kAmp, kPitch, kFilter };
// The envelope a deck's overlay-select radio picks; kNone for any other control id.
OverlayEnv overlayEnvForRadio(int radioId);
// The selection a click on `radioId` produces from `current`. Two rules, provable here rather
// than in the shell: picking another deck's radio switches to it (exclusivity), and clicking
// the ACTIVE one clears back to kNone — "no envelope shown" is a state the user can get back
// to, not an error. A non-radio id leaves the selection alone.
OverlayEnv nextOverlaySelection(OverlayEnv current, int radioId);
// Whether the overlay for `env` is INERT: its deck group's enable toggle is off, so its knobs
// are drawn-but-dead and a node drag on the same params must be too — otherwise a drag reaches
// a param a knob couldn't (envelope_edit.h). Amp has no enable toggle and is never inert.
bool overlayEnvInert(OverlayEnv env, bool pitchEnvEnabled, bool filterEnabled);
// The deck's BIPOLAR knob law: 0.5 of the knob's travel is zero depth, the ends are -1 and
// +1. Exact inverses, and exact at the centre detent (0.5 -> 0 -> 0.5), so a knob parked at
// centre can never persist a hair of modulation. Out-of-range norm clamps to the endpoints.
+7 -5
View File
@@ -11,11 +11,13 @@ per-segment envelope curve law.
- `file_bytes` (`core/util`) — the ONE whole-file byte loader (Q-W1), linked by both artifacts; blocking I/O, off-audio-thread only.
- `clamp01` (`core/util`, header-only) — the ONE unit-interval clamp (Q-W1), replacing four per-module static copies; NaN passes through unchanged rather than collapsing to a bound.
- `curve_law` (`core/util`, header-only) — the ONE per-segment envelope curve law: the
exponent domain (0.1..10, neutral 1.0), the normalized-position -> normalized-level map, and
the mid-segment inverse an overlay knot drags through. Header-only and dependency-free so
the engine's evaluator, the overlay's forward map, its inverse, and the deck's inner dial all
read one law instead of four copies. **The neutral exponent is the IDENTITY, bit for bit**
that is what makes an instance saved before curves existed play unchanged.
exponent domain (0.1..10, neutral 1.0), the normalized-position -> normalized-level map, the
mid-segment inverse an overlay knot drags through, and the inner dial's own norm<->exponent
travel. Header-only and dependency-free so the engine's evaluator, the overlay's forward map,
its inverse, and the deck's inner dial all read one law instead of four copies.
**The neutral exponent is the IDENTITY, bit for bit** — that is what makes an instance saved
before curves existed play unchanged, and what the knob law's centre detent exists to keep
reachable from the dial.
- `relative_path` (`core/util`, header-only) — the ONE absolute-path rejection test behind the relative-paths-only invariant, shared by `bank_model` (`Sample.relativePath`) and `core/tracking/origin_ledger` (`OriginRecord.relativePath`). The two must reject identically or a path one accepts could be smuggled past the other; that is why it is one function and not two.
## Gotchas
+29
View File
@@ -19,6 +19,12 @@ inline constexpr double kCurveMax = 10.0;
// at-rest per-sample path pays one predicted branch instead of a transcendental; every
// positive exponent maps 0 -> 0 and 1 -> 1, so a curved stage can never overshoot its own
// endpoint levels.
//
// MEASURED (Release, MSVC 19.44 x64, one dev machine): a non-neutral exponent costs ~4.7 ns per
// evaluation. At the 16-voice default with all three envelopes in a sloped stage — three
// evaluations per voice per frame, the worst case — that is +224 ns per output frame: 3.1% of
// one core at 44.1 kHz becomes 4.1%. Affordable at the shipped voice ceiling; re-measure before
// putting a fourth per-voice curve on the frame.
inline double curveMap(double phi, double exponent) {
if (exponent == kCurveNeutral) return phi;
return std::pow(phi, exponent);
@@ -29,6 +35,29 @@ inline double clampCurve(double exponent) {
return exponent > kCurveMax ? kCurveMax : exponent;
}
// The exponent's KNOB travel: logarithmic, so the two halves of the throw are the reciprocal
// shaping directions. Written as kCurveMax^(2t-1) rather than as an interpolation between
// log(kCurveMin) and log(kCurveMax) so t == 0.5 evaluates exp(0) == 1.0 EXACTLY — an inexact
// centre would put std::pow on the per-sample path for a stage the user believes is parked at
// the identity. Requires kCurveMin == 1/kCurveMax, which the domain above satisfies.
//
// The detent is what makes the identity REACHABLE: a knob drag delivers start - dy/128, a grid
// that lands on 0.5 only by luck, so a band wider than one drag step (1/128) snaps to neutral
// and a dial swept through the centre cannot skip over it.
inline constexpr double kCurveKnobDetent = 0.01;
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)));
}
inline double knobNormFromCurve(double exponent) {
const double t = 0.5 + std::log(clampCurve(exponent)) / (2.0 * std::log(kCurveMax));
return t < 0.0 ? 0.0 : (t > 1.0 ? 1.0 : t);
}
// The normalized level at a segment's MIDPOINT (phi = 0.5) — where the overlay places the
// draggable curve knot — and its inverse. The pair is what keeps knot-drag and inner dial on
// one value: both resolve through this law, not through each other.