instrument: fix AHD node-tracking/tie-break/live-latch defects and close staged-envelope-curve test gaps
This commit is contained in:
@@ -255,7 +255,7 @@ anything for a trigger shape.
|
||||
- `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.
|
||||
- `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.
|
||||
- `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`.
|
||||
- `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.
|
||||
- `pitch_shift` — hand-rolled **correlation-aligned SOLA** (splice-overlap-add) pitch shifter for the Preserve playback mode: one active read tap chases the write head at the shift ratio; each splice jump is refined by a cross-correlation search so the new read point is waveform-aligned, then old and new taps are crossfaded (raised-cosine, amplitude-complementary). Replaces the prior dual-tap OLA whose fixed half-window tap offset caused anti-phase cancellation on many source frequencies. **GA2:** ring buffer **primed with the actual upcoming source** at note-on (was zero-filled) → gap-free frame-0 onset, ~25 ms Preserve onset latency eliminated (Preserve now speaks on frame 0, matching Varispeed), and real-content-bounded tail (last-window tail-truncation gone). No third-party dependencies; RT-discipline: no allocation in `process()`.
|
||||
- `velocity_curve` — pure velocity→amp transfer curve: `VelocityCurve` evaluated by a Fritsch–Carlson monotone cubic Hermite spline (no overshoot outside [0,1]). `eval(velocity)` called once per note-on. `flat()` default (y=1, every velocity→unity) replaces the prior fixed `velocity/127` path — a deliberate non-back-compat behavior change (Daniel-approved).
|
||||
@@ -264,7 +264,7 @@ anything for a trigger shape.
|
||||
### `map/`
|
||||
|
||||
- `sample_map` — the bank blob → selected capture resolve, the channel policy (downmix / dual-mono / L-R split), `InstrumentParams` (the ONE parameter set: root/loop/start overrides, keyTrack, velocity curve, `PlaySeconds`), the single override-beats-intrinsic fold (`resolveCapture`, shared by the bank and refs paths so they cannot drift), and the `SampleData` build. **Wall-clock times stored as rate-free SECONDS, resolved against the live project rate — NO hardcoded sample rates in `src/`** (Daniel's standing ruling, load-bearing). Deliberately does NOT link the voice engine: the build's product is plain `SampleData`.
|
||||
- `component_state_io` (`core/instrument/map`) — the `ComponentState` envelope + params-payload binary codec (envelope v1…v11, params payload v1…v9), split out of `sample_map` (Q-W2v, T4-13 ≡ T2-07) so BOTH artifacts can link the codec without the extension pulling in the whole voice engine to serialize one preset blob — the extension's `instrument_drop` and the instrument's processor read/write the identical bytes, so the cross-artifact contract cannot drift. Payload v1…v7 are the RETIRED per-zone lists: still read, lifting by adopting zone one's capture + parameters (that first zone is what the old first-match resolve actually played, so it is also what supersedes the envelope's stored selection id). Payload v9 appends the per-voice filter tail; a v8 blob is a strict prefix of it and lifts to the off/neutral filter default.
|
||||
- `component_state_io` (`core/instrument/map`) — the `ComponentState` envelope + params-payload binary codec (envelope v1…v11, params payload v1…v10), split out of `sample_map` (Q-W2v, T4-13 ≡ T2-07) so BOTH artifacts can link the codec without the extension pulling in the whole voice engine to serialize one preset blob — the extension's `instrument_drop` and the instrument's processor read/write the identical bytes, so the cross-artifact contract cannot drift. Payload v1…v7 are the RETIRED per-zone lists: still read, lifting by adopting zone one's capture + parameters (that first zone is what the old first-match resolve actually played, so it is also what supersedes the envelope's stored selection id). Payload v9 appends the per-voice filter tail; a v8 blob is a strict prefix of it and lifts to the off/neutral filter default.
|
||||
- `params_payload` — the PARAMS-PAYLOAD half of that codec, split from the envelope half on the axis the format already has: the payload carries its own version and grows independently, so the two version ladders are two responsibilities. An INTERNAL seam — the public entry points stay `serialize`/`deserializeComponentState`. The prose ladder and every version constant stay in `component_state_io.h`, their one home.
|
||||
- `bank_sync` — generation change-detection + assignment-request consume: owns the yes/no decision logic so the rules are provable without a host. The processor shell owns cadence and side effects.
|
||||
- `bridge_marshal` — pure marshalling helper for the REAPER VST-host bridge read: interprets the `GetProjExtState` int return against its filled buffer.
|
||||
|
||||
@@ -20,7 +20,7 @@ add_subdirectory(filter)
|
||||
# engine — the block is a plain value the voice observes, not a thing the engine owns.
|
||||
reasampler_pure_library(live_params
|
||||
SOURCES live_params.cpp
|
||||
LINK PUBLIC peaks velocity_curve filter)
|
||||
LINK PUBLIC peaks velocity_curve filter curve_law)
|
||||
reasampler_test(live_params LINK live_params)
|
||||
|
||||
# Two TUs on the engine's own responsibility seam (per-note setup vs. note routing and
|
||||
@@ -28,7 +28,7 @@ reasampler_test(live_params LINK live_params)
|
||||
# boundary costs the hot path nothing.
|
||||
reasampler_pure_library(sampler_core
|
||||
SOURCES voice.cpp voice_engine.cpp
|
||||
LINK PUBLIC peaks pitch_shift velocity_curve filter live_params)
|
||||
LINK PUBLIC peaks pitch_shift velocity_curve filter live_params curve_law)
|
||||
# Links only sampler_core: linking more would break the plain-data-boundary proof — a VST3
|
||||
# or REAPER type reaching the core would fail to compile or link here.
|
||||
reasampler_test(sampler_core LINK sampler_core)
|
||||
|
||||
@@ -346,14 +346,14 @@ public:
|
||||
// Trigger amp and filter envelopes. A zero/negative span finishes immediately.
|
||||
void configure(std::int64_t spanFrames, const AhdParams& params) {
|
||||
span_ = spanFrames > 0 ? spanFrames : 0;
|
||||
fit(params);
|
||||
fit(params, /*latchFinished=*/false); // a fresh note starts from a clean read
|
||||
smooth_.clear();
|
||||
}
|
||||
|
||||
// Peer of AdsrEnvelope::snapLive: a voice that has rendered nothing takes the new shape
|
||||
// outright, with no step to absorb.
|
||||
void snapLive(const AhdParams& params) {
|
||||
fit(params);
|
||||
fit(params, /*latchFinished=*/false);
|
||||
smooth_.clear();
|
||||
}
|
||||
|
||||
@@ -361,7 +361,10 @@ public:
|
||||
// why this smooths rather than holding a normalized position.
|
||||
void applyLive(double sourceOffset, const AhdParams& params) {
|
||||
const double before = ahdLevelAt(sourceOffset, fit_, attackCurve_, decayCurve_);
|
||||
fit(params);
|
||||
// LATCHED: a voice already read past its fitted total must never resurge because a
|
||||
// later live move reopened the total. Reachable on any active() voice, including one
|
||||
// ringing out past its own end (voice.h) where tickAmplitude() still runs.
|
||||
fit(params, /*latchFinished=*/true);
|
||||
const double after = ahdLevelAt(sourceOffset, fit_, attackCurve_, decayCurve_);
|
||||
if (after != before) smooth_.absorb(before - after);
|
||||
}
|
||||
@@ -381,11 +384,15 @@ public:
|
||||
const AhdSpan& stages() const { return fit_; }
|
||||
|
||||
private:
|
||||
void fit(const AhdParams& p) {
|
||||
// `latchFinished`: once true, a re-fit can only ever KEEP finished_ true, never clear it —
|
||||
// see applyLive above for why. configure()/snapLive() pass false: those are a fresh read
|
||||
// (new note or a not-yet-rendered voice), which must compute finished_ from scratch.
|
||||
void fit(const AhdParams& p, bool latchFinished) {
|
||||
fit_ = fitAhd(span_, p);
|
||||
attackCurve_ = p.attackCurve;
|
||||
decayCurve_ = p.decayCurve;
|
||||
finished_ = (fit_.total <= 0);
|
||||
const bool empty = (fit_.total <= 0);
|
||||
finished_ = latchFinished ? (finished_ || empty) : empty;
|
||||
}
|
||||
|
||||
std::int64_t span_ = 0;
|
||||
|
||||
@@ -55,8 +55,8 @@ struct AdsrParams {
|
||||
|
||||
// Attack -> Hold -> Decay over a bounded span: the shape every SUSTAIN-LESS envelope takes
|
||||
// (the Trigger amp, the Trigger filter envelope, the pitch envelope). Hold is a FRACTION of
|
||||
// the span left after attack and decay, never a time of its own — that is what makes
|
||||
// A + H + D <= span structural rather than clamped (see fitAhd in envelopes.h).
|
||||
// the span left after attack and decay, never a time of its own — fitAhd (envelopes.h) owns
|
||||
// why a fraction, not a time.
|
||||
struct AhdParams {
|
||||
std::int64_t attackFrames = 0;
|
||||
std::int64_t decayFrames = 0;
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
// configured an out-of-line render would put a call — and the envelope ticks behind it —
|
||||
// across a TU boundary on the hottest path in the program. The per-NOTE half (start /
|
||||
// retune / release / hardStop / presize) is cold enough to live in voice.cpp.
|
||||
//
|
||||
// DOCUMENTED ~600-line-ceiling EXCEPTION (root CLAUDE.md structural heuristic 1): this file
|
||||
// is over the ceiling because of the constraint above, not silent overshoot. A responsibility
|
||||
// seam here would move part of advanceFrame's inline body out of this header, reintroducing
|
||||
// the cross-TU call the header-inlining exists to avoid — worse than the overshoot.
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
|
||||
@@ -21,7 +21,7 @@ reasampler_test(bank_sync LINK bank_sync)
|
||||
# the format already keeps on independent version axes (see component_state_io.h).
|
||||
reasampler_pure_library(component_state_io
|
||||
SOURCES component_state_io.cpp params_payload.cpp
|
||||
LINK PUBLIC velocity_curve master_gain)
|
||||
LINK PUBLIC velocity_curve master_gain curve_law)
|
||||
# Links only component_state_io, deliberately no sampler_core/pitch_shift: the structural
|
||||
# proof the codec is engine-free, which is what keeps engine object code out of the extension.
|
||||
reasampler_test(component_state_io LINK component_state_io)
|
||||
@@ -29,7 +29,7 @@ reasampler_test(component_state_io LINK component_state_io)
|
||||
# The mapping's product is plain SampleData, so the voice engine is not a dependency.
|
||||
reasampler_pure_library(sample_map
|
||||
SOURCES sample_map.cpp
|
||||
LINK PUBLIC bank_book wav_codec velocity_curve peaks)
|
||||
LINK PUBLIC bank_book wav_codec velocity_curve peaks curve_law)
|
||||
# Links only sample_map + component_state_io: the same plain-data-boundary proof, spanning
|
||||
# both halves of the mapping/codec split where the frozen-format assertions live.
|
||||
reasampler_test(sample_map LINK sample_map component_state_io)
|
||||
|
||||
@@ -17,15 +17,13 @@ namespace reasampler::instrument::map {
|
||||
|
||||
using engine::masterGainMaxLinear;
|
||||
using reasampler::wire::ByteReader;
|
||||
using reasampler::wire::asU64;
|
||||
using reasampler::wire::bitsToDouble;
|
||||
using reasampler::wire::doubleToBits;
|
||||
using reasampler::wire::putLE;
|
||||
|
||||
namespace {
|
||||
|
||||
// Signed 64-bit values ride the wire as their two's-complement unsigned image.
|
||||
std::uint64_t asU64(std::int64_t v) { return static_cast<std::uint64_t>(v); }
|
||||
|
||||
// Apply a payload read to the state: the adoption rule (a retired payload's first zone
|
||||
// supersedes the envelope's selection id) lives here, once.
|
||||
void applyPayload(ComponentState& out, PayloadRead read) {
|
||||
|
||||
@@ -14,15 +14,13 @@
|
||||
namespace reasampler::instrument::map {
|
||||
|
||||
using reasampler::wire::ByteReader;
|
||||
using reasampler::wire::asU64;
|
||||
using reasampler::wire::bitsToDouble;
|
||||
using reasampler::wire::doubleToBits;
|
||||
using reasampler::wire::putLE;
|
||||
|
||||
namespace {
|
||||
|
||||
// Signed 64-bit values ride the wire as their two's-complement unsigned image.
|
||||
std::uint64_t asU64(std::int64_t v) { return static_cast<std::uint64_t>(v); }
|
||||
|
||||
// Emit the OVERRIDE trio shared by the v2..v7 per-zone record and the v8 single record, so
|
||||
// the two shapes cannot drift byte-for-byte.
|
||||
void putOverrides(std::vector<std::uint8_t>& out, const InstrumentParams& p) {
|
||||
@@ -149,8 +147,14 @@ double readCurveExponent(ByteReader& r) {
|
||||
}
|
||||
|
||||
void readAhd(ByteReader& r, AhdSeconds& a) {
|
||||
a.attackSeconds = bitsToDouble(r.u64());
|
||||
a.decaySeconds = bitsToDouble(r.u64());
|
||||
// attackSeconds/decaySeconds reach resolvePlay's static_cast<std::int64_t> (sample_map.cpp)
|
||||
// unguarded — UB on NaN, and on a large-enough finite value — so a corrupt/non-finite wire
|
||||
// value degrades to 0 seconds rather than reaching that cast, the same guard readSecondsPlayTail
|
||||
// and the v9 filter tail already apply to their own wall-clock fields.
|
||||
const double attack = bitsToDouble(r.u64());
|
||||
const double decay = bitsToDouble(r.u64());
|
||||
a.attackSeconds = std::isfinite(attack) ? attack : 0.0;
|
||||
a.decaySeconds = std::isfinite(decay) ? decay : 0.0;
|
||||
const double frac = bitsToDouble(r.u64());
|
||||
a.holdFraction = std::isfinite(frac) ? frac : 0.0;
|
||||
a.attackCurve = readCurveExponent(r);
|
||||
|
||||
@@ -203,8 +203,8 @@ DecodedPcm decodeChannels(const std::vector<AudioSample>& interleaved,
|
||||
}
|
||||
|
||||
PlayParams resolvePlay(const PlaySeconds& stored, int sampleRate) {
|
||||
// seconds -> frames at the LIVE rate; source-timeline quantities (trigger %-length +
|
||||
// fades) carry through untouched, already frames/fractions.
|
||||
// seconds -> frames at the LIVE rate; the source-timeline quantity (trigger %-length)
|
||||
// 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) {
|
||||
|
||||
@@ -35,7 +35,7 @@ reasampler_test(browser_scroll LINK browser_scroll)
|
||||
reasampler_pure_library(param_slider SOURCES param_slider.cpp LINK PUBLIC editor_geometry)
|
||||
reasampler_test(param_slider LINK param_slider)
|
||||
|
||||
reasampler_pure_library(envelope_overlay SOURCES envelope_overlay.cpp LINK PUBLIC editor_geometry)
|
||||
reasampler_pure_library(envelope_overlay SOURCES envelope_overlay.cpp LINK PUBLIC editor_geometry curve_law)
|
||||
reasampler_test(envelope_overlay LINK envelope_overlay)
|
||||
|
||||
reasampler_pure_library(envelope_edit SOURCES envelope_edit.cpp LINK PUBLIC envelope_overlay)
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include "core/util/clamp01.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath> // std::fabs
|
||||
#include <cstdlib> // std::abs
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
@@ -91,15 +92,20 @@ SegmentLevels segmentLevels(const StageEnvelope& env, EnvNode knot) {
|
||||
}
|
||||
|
||||
// A knot drag: the grab-time mid-level shifted by the pixel delta, read back through
|
||||
// curve_law's inverse. Both directions go through the ONE law, which is why the knot and the
|
||||
// inner dial cannot express different exponents.
|
||||
// curve_law's inverse (curve_law.h owns why the knot and the inner dial share this one law).
|
||||
double curveFromKnotDrag(const StageEnvelope& grabEnv, EnvNode knot, double grabExponent,
|
||||
const Rect& area, int dyPixels) {
|
||||
const SegmentLevels seg = segmentLevels(grabEnv, knot);
|
||||
if (!seg.ok) return grabExponent;
|
||||
const double grabLevel = seg.start + (seg.end - seg.start) * curveMidLevel(grabExponent);
|
||||
const double span = seg.end - seg.start;
|
||||
// segmentLevels only rejects an EXACTLY level segment; a near-level one (e.g. sustain
|
||||
// 0.99) still passes with a tiny divisor here, so one pixel of drag can swing `u` by
|
||||
// ~1.0 and saturate the exponent. Floor the magnitude at a couple of pixels' worth of
|
||||
// level travel — a segment thinner than that is visually a no-op drag anyway.
|
||||
if (std::fabs(span) < 2.0 * levelPerPixel(area)) return grabExponent;
|
||||
const double grabLevel = seg.start + span * curveMidLevel(grabExponent);
|
||||
const double newLevel = grabLevel - static_cast<double>(dyPixels) * levelPerPixel(area);
|
||||
return curveFromMidLevel((newLevel - seg.start) / (seg.end - seg.start));
|
||||
return curveFromMidLevel((newLevel - seg.start) / span);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
@@ -196,10 +202,21 @@ StageEnvelope resolveNodeDrag(const StageEnvelope& grabEnv, EnvNode node, const
|
||||
out.holdFraction = clamp01((s.hold + dSec) / rem);
|
||||
break;
|
||||
}
|
||||
case EnvNode::DecayEnd:
|
||||
out.decaySeconds =
|
||||
std::clamp(grabEnv.decaySeconds + dSec, 0.0, bounds.maxDecaySeconds);
|
||||
case EnvNode::DecayEnd: {
|
||||
// DecayEnd is DRAWN at t0 + total, and total = attack + decay + (span-attack-decay)
|
||||
// * holdFraction, so d(total)/d(decay) = 1 - holdFraction: Hold eats a holdFraction
|
||||
// share of whatever decay gives up. Scaling by 1/(1-frac) makes the drawn endpoint
|
||||
// track the cursor 1:1, matching every other node. At frac == 1.0 (the Trigger
|
||||
// default) Hold consumes the WHOLE remainder regardless of decay's value, so the
|
||||
// derivative is exactly 0 — no scale recovers motion there, and decaySeconds is left
|
||||
// unchanged rather than divided by zero.
|
||||
const double denom = 1.0 - clamp01(grabEnv.holdFraction);
|
||||
if (denom > 1e-9) {
|
||||
out.decaySeconds =
|
||||
std::clamp(grabEnv.decaySeconds + dSec / denom, 0.0, bounds.maxDecaySeconds);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case EnvNode::AttackCurve:
|
||||
out.attackCurve =
|
||||
curveFromKnotDrag(grabEnv, node, grabEnv.attackCurve, rect, dyPixels);
|
||||
|
||||
@@ -1,21 +1,13 @@
|
||||
// envelope_edit.h — node hit-test + pixel-delta -> clamped-param inverse map for the draggable
|
||||
// envelope nodes and their mid-segment curve knots. Mirror of card_drag/waveform_view: drag
|
||||
// arithmetic lives here, unit-tested outside the DAW; the shell draws handles, captures the
|
||||
// grab, and feeds pixel deltas back in.
|
||||
//
|
||||
// envelope_overlay owns the params->polyline forward (draw) map; this module owns the inverse
|
||||
// (edit) map + hit-test. Both read/write the same StageEnvelope fields (the shell re-reads the
|
||||
// one parameter set every paint), so a node drag, a knot drag, and a knob edit are three views
|
||||
// on one source of truth — structurally, not through a listener chain.
|
||||
//
|
||||
// A drag can never produce a param a knob couldn't: time nodes are range-clamped to the same
|
||||
// per-param [min,max] the knobs enforce (EnvClampBounds, caller-supplied since those maxima
|
||||
// live shell-side), and a knot resolves through curve_law's own exponent domain.
|
||||
// envelope nodes and their mid-segment curve knots. The inverse of envelope_overlay's forward
|
||||
// (draw) map; both read/write the same StageEnvelope fields, so node drag / knot drag / knob
|
||||
// edit are one model — see core/instrument/CLAUDE.md's "envelope overlay" section for why.
|
||||
// Mirror of card_drag/waveform_view: drag arithmetic lives here, unit-tested outside the DAW.
|
||||
//
|
||||
// Time-only nodes drag on X; DecayEnd in an AHDSR drags on both axes (X = decay time, Y =
|
||||
// sustain level); a curve knot drags on Y alone. Origin is never draggable, and neither is an
|
||||
// AHDSR's ReleaseEnd — it is anchored to the right edge, and release is dragged from
|
||||
// ReleaseStart instead. A node is only editable in its own kind.
|
||||
// sustain level); a curve knot drags on Y alone (curve_law.h owns the exponent domain). Origin
|
||||
// is never draggable, and neither is an AHDSR's ReleaseEnd — anchored to the right edge, with
|
||||
// release dragged from ReleaseStart instead. A node is only editable in its own kind.
|
||||
|
||||
#pragma once
|
||||
|
||||
|
||||
@@ -90,8 +90,7 @@ EnvVertex gateVtx(EnvNode node, const Rect& area, double px, double level, bool
|
||||
}
|
||||
|
||||
// The knot for a segment running from `startLevel` to `endLevel`, placed at the segment's
|
||||
// pixel midpoint. Its level is the curve's own value at the segment midpoint, which is what
|
||||
// makes the knot's height and the inner dial two readings of one exponent.
|
||||
// pixel midpoint, its level read through curve_law.h's own law (the knot/dial pairing's home).
|
||||
EnvVertex knotVtx(EnvNode node, const Rect& area, int x0, int x1, double startLevel,
|
||||
double endLevel, double exponent) {
|
||||
const double u = curveMidLevel(exponent);
|
||||
@@ -178,8 +177,19 @@ std::vector<EnvVertex> ahdPolyline(const StageEnvelope& env, const Rect& area,
|
||||
pts.reserve(6);
|
||||
pts.push_back(vtx(EnvNode::Origin, area, totalSeconds, t0, 0.0));
|
||||
pts.push_back(vtx(EnvNode::AttackEnd, area, totalSeconds, t0 + s.attack, 1.0));
|
||||
pts.push_back(vtx(EnvNode::HoldEnd, area, totalSeconds, t0 + s.attack + s.hold, 1.0));
|
||||
pts.push_back(vtx(EnvNode::DecayEnd, area, totalSeconds, t0 + s.total, 0.0));
|
||||
EnvVertex holdVtx = vtx(EnvNode::HoldEnd, area, totalSeconds, t0 + s.attack + s.hold, 1.0);
|
||||
EnvVertex decayVtx = vtx(EnvNode::DecayEnd, area, totalSeconds, t0 + s.total, 0.0);
|
||||
// A hold that consumes the WHOLE post-attack/decay remainder (holdFraction == 1.0, the
|
||||
// Trigger AHD default) puts HoldEnd and DecayEnd on the same wall-clock instant, and
|
||||
// nodeAtPoint's earlier-draw-order tie-break then hides DecayEnd behind HoldEnd forever.
|
||||
// Nudge apart, clamped to the canvas — the same minimum-separation rationale
|
||||
// kGateNodeSepPx exists for on the AHDSR schematic, applied to this coincidence instead.
|
||||
if (decayVtx.x - holdVtx.x < kGateNodeSepPx) {
|
||||
const int right = area.x + std::max(1, area.width) - 1;
|
||||
decayVtx.x = std::min(holdVtx.x + kGateNodeSepPx, right);
|
||||
}
|
||||
pts.push_back(holdVtx);
|
||||
pts.push_back(decayVtx);
|
||||
if (s.attack > 0.0) {
|
||||
pts.push_back(knotVtx(EnvNode::AttackCurve, area, pts[0].x, pts[1].x, 0.0, 1.0,
|
||||
env.attackCurve));
|
||||
|
||||
@@ -13,10 +13,9 @@
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
// Which LAYOUT POLICY an envelope takes, decided by whether it has a sustain stage rather
|
||||
// than by which processor it modulates. A gated (AHDSR) envelope right-anchors its release so
|
||||
// the sustain plateau reads full-width; a sustain-less (AHD) one maps 1:1 onto the waveform's
|
||||
// own time axis, which only means anything for a trigger shape. The two policies coexist.
|
||||
// Which LAYOUT POLICY an envelope takes — see core/instrument/CLAUDE.md's "envelope overlay"
|
||||
// section for why (decided by sustain-stage presence, not by which processor it modulates).
|
||||
// Ahdsr right-anchors its release; Ahd maps 1:1 onto the waveform's own time axis.
|
||||
enum class EnvKind { Ahdsr, Ahd };
|
||||
|
||||
// Gate nodes: Origin -> AttackEnd -> HoldEnd -> DecayEnd(sustain) -> ReleaseStart -> ReleaseEnd.
|
||||
|
||||
Reference in New Issue
Block a user