instrument: spline EGs — hard points on the one shared spline, a drawn contour per envelope beside its staged state, payload v13

This commit is contained in:
2026-07-31 21:33:59 -04:00
parent f115904e4f
commit e44bd42dd9
33 changed files with 1739 additions and 364 deletions
+27 -5
View File
@@ -222,7 +222,28 @@ range-clamped to the same per-param min/max the knobs enforce, so no drag can pr
param a knob couldn't. Two pure modules split the forward (draw) and inverse (edit) maps —
see `envelope_overlay` and `envelope_edit` in Modules below.
**Which shape an envelope takes is decided by the play mode, not by what it modulates:**
**Every envelope is EITHER staged or drawn, and both states persist.** Each of the three
(amp, pitch, filter) carries a `SplineEnv` — a mode plus a contour over NORMALIZED sample time —
beside its staged parameters. Switching modes converts and discards nothing: the inactive state
stays saved but inert, and round-tripping restores the other mode's shape untouched. The
consequences, each with one home:
- **Gate is unavailable while any EG is drawn.** A contour is a pure time function over the full
sample length, which IS the Trigger/one-shot model. `splineActive` (`play_params.h`) is the
predicate; `resolvePlay` enforces it on the way to the engine and the editor's Gate segment
refuses and paints Disabled off the same predicate.
- **A drawn envelope's staged segment knobs go inert** — drawn-but-dead, never removed, never
hidden — including their inner curve dials, which are reached through their outer cell.
`deckKnobInert` (`ui/deck_groups`) is the one place that list lives. The DEPTH knobs (pitch
peak, filter mod amount) stay live: they scale whichever shape is active.
- **Normalized is what makes a contour length-independent.** There are no stored seconds to
rescale, so a different-length capture replays the same shape proportionally.
- **The contours sit on `PlayParams`/`PlaySeconds` directly, not inside the three envelope
structs.** Those are copied whole into the live block, which must stay trivially copyable
(`live_params.h`) — and a contour is not a live control anyway: like the velocity curves it
travels by reload.
**Which shape a STAGED envelope takes is decided by the play mode, not by what it modulates:**
pitch is always AHD; amp and filter are AHDSR in Gate and AHD in Trigger. Both mode shapes
are STORED per envelope, so flipping modes cannot lose either mode's dialled values (the
migration case forces it: an old instance carries both its AHDSR values and its Trigger
@@ -262,13 +283,13 @@ anything for a trigger shape.
- `voice_engine.h` / `voice_engine.cpp` — `VoiceEngine`: note routing, bounded-stealing allocation, user-parameterized voice count (132, default 16), `VoiceMode` Poly/Mono (last-note held-note stack, `MonoTrigger` Retrigger/Legato), two-tier panic (CC 123 = all-notes-off release, CC 120 = immediate hard-stop including Trigger one-shots), and the block render loops. Preview injects a synthetic note-on at the loaded capture's root note into the main `VoiceEngine` — no dedicated `PreviewCard`; preview obeys polyphony/mono/voice-stealing/envelopes.
- `engine/loop/` — the sustain loop's ONE validity/clamp fold (`resolveLoop`) plus its pre-seam crossfade geometry and the editor's default handle span; see `engine/loop/CLAUDE.md`. The voice folds it once at note-on; the crossfade weight is header-inline because it rides the per-sample read.
- `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` — the pure velocity transfer curve shared by all THREE destinations: `VelocityCurve` evaluated by a FritschCarlson monotone cubic Hermite spline (no overshoot). `eval(velocity)` called once per note-on. It carries its own y `CurveDomain`: UNIPOLAR [0,1] is the amp's GAIN, defaulting to `flat()` (y=1, every velocity→unity — a deliberate non-back-compat replacement of the old fixed `velocity/127` path, Daniel-approved); BIPOLAR [1,1] is the signed modulation shape for pitch and filter, defaulting to `zero()` so velocity modulates neither until a curve is drawn. A bipolar curve does not imply the absence of a depth beside it: the filter keeps its `velAmount` knob and the two compose multiplicatively (`velAmount × curve.eval(v)`, `play_params.h`), while the pitch curve's throw is the fixed `kVelocityPitchRangeSemitones`.
- `velocity_curve` — THE monotone spline, shared by every consumer: the three velocity transfer curves and the three spline EGs. `VelocityCurve` is evaluated as ONE OR MORE FritschCarlson monotone cubic Hermite splines joined at its HARD points — a hard knot is a sub-curve boundary for tangent purposes (exactly what the point array's own ends already are), so the two adjacent segments meet at their natural angle instead of a shared derivative and the no-overshoot guarantee holds PER SEGMENT rather than globally. Points are smooth by default; the ceiling is `kMaxCurvePoints` = 128, a MUSICAL bound (long rhythmic phrases, ~two points per articulation event) and not a performance one — **do not lower it**. `eval(velocity)` is the COLD reader, called once per note-on or once per drawn pixel column; `SplineCursor` is the RT one, an indexed segment search plus one Hermite evaluation with the segment and its tangents cached across samples. Both share the same `segmentTangents`/`hermiteAt` free functions, so there is one spline and not two. It carries its own y `CurveDomain`: UNIPOLAR [0,1] is the amp's GAIN, defaulting to `flat()` (y=1, every velocity→unity — a deliberate non-back-compat replacement of the old fixed `velocity/127` path, Daniel-approved); BIPOLAR [1,1] is the signed modulation shape for pitch and filter, defaulting to `zero()` so velocity modulates neither until a curve is drawn. A bipolar curve does not imply the absence of a depth beside it: the filter keeps its `velAmount` knob and the two compose multiplicatively (`velAmount × curve.eval(v)`, `play_params.h`), while the pitch curve's throw is the fixed `kVelocityPitchRangeSemitones`.
- `master_gain` — pure dB↔linear taper math (FB1): normalized [0,1] ↔ dB ↔ linear for the post-mixer master gain control (−∞…+24 dB, norm 0 = true silence, unity ≈ 0.714). Shared by the editor knob and the processor multiply so the needle, persisted value, and audio multiply cannot drift.
### `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…v12), 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. Every tail since is a strict suffix on the same discipline — v10 the staged curves, v11 the loop crossfade, v12 the velocity→pitch curve. v12 also RE-TAGS the y DOMAIN of one frozen slot inside the v9 filter tail — its velocity curve reads bipolar from v12 on, unipolar before — which needs no version branch, because a pre-v12 curve's y values are already valid bipolar ones; every other filter slot, `velAmount` included, keeps its meaning.
- `component_state_io` (`core/instrument/map`) — the `ComponentState` envelope + params-payload binary codec (envelope v1…v11, params payload v1…v12), 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. Every tail since is a strict suffix on the same discipline — v10 the staged curves, v11 the loop crossfade, v12 the velocity→pitch curve, v13 the dual Staged/Spline state (the three contours, plus hard-flag tails for the three velocity curves — their v7/v9/v12 blocks are frozen at 16 bytes/point and had no room for a per-point flag). v12 also RE-TAGS the y DOMAIN of one frozen slot inside the v9 filter tail — its velocity curve reads bipolar from v12 on, unipolar before — which needs no version branch, because a pre-v12 curve's y values are already valid bipolar ones; every other filter slot, `velAmount` included, keeps its meaning.
- `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.
@@ -286,8 +307,9 @@ anything for a trigger shape.
- `browser_scroll` — scroll + type-to-filter layered over `capture_browser`: vertical scroll offset, scrollbar thumb, thumb-drag mapping, and name-substring search.
- `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), 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 velocity/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. Also home to `CurveTarget` + `curveTargetFor` — the VELOCITY group's three cells are popup openers, not dials, and that predicate is the ONE place they are named, so paint, hit-test routing and the popup's title all agree. MASTER is reserved for post-voice-mixer concerns, which is why the curves sit in their own group immediately left of VOICE rather than there.
- `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. A group carries TWO caption-toggle slots, laid right-to-left: the second exists because a group whose knob row is wider than its caption row has caption slack a toggle can occupy for free, and the deck has six pixels of headroom on its first row at the editor's floor width — a `rowToggle` would widen the GROUP and wrap the deck to a fourth row, past what the minimum window holds.
- `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`/`overlayEnvEnabled`/`overlayEnvInert`, the whole overlay-selection state machine (exclusivity, the none resting state, and which selections a disabled or DRAWN 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 velocity/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. Also home to `CurveTarget` + `curveTargetFor` — the VELOCITY group's three cells are popup openers, not dials, and that predicate is the ONE place they are named, so paint, hit-test routing and the popup's title all agree. MASTER is reserved for post-voice-mixer concerns, which is why the curves sit in their own group immediately left of VOICE rather than there.
- `spline_edit` — THE point-editing grammar, and the one place it is written down: left-click grabs a node and adds one in empty space, right-click deletes, control-click toggles hard/smooth. Both spline consumers — the velocity-curve popup and the spline EG overlay — route their mouse-down through `resolveSplineEdit`, so the two cannot drift into two grammars. The endpoint and point-count rules are NOT restated here: `deletePoint` and `addPoint` own them, and the caller applies the resolved action to the curve. Also home to `splineOverlayBox`, the contour's mapping box inside the waveform overlay — the FULL area, no inset, so the drawn contour stays 1:1 with the sample's time axis.
- `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.
+5
View File
@@ -2,3 +2,8 @@ add_subdirectory(engine)
add_subdirectory(map)
add_subdirectory(note)
add_subdirectory(ui)
# The spline EG spans all three: the shared curve + its RT cursor (engine), the dual-state
# persistence (map), and the point-editing grammar (ui). Declared here because no one
# subdirectory owns the seam it covers.
reasampler_test(spline_egs LINK sampler_core sample_map component_state_io spline_edit deck_groups)
+37
View File
@@ -65,6 +65,20 @@ struct AhdParams {
double decayCurve = util::kCurveNeutral;
};
// Which shape an envelope takes: the STAGED knobs, or a free-drawn SPLINE contour. Both states
// are stored side by side and neither converts into the other, so a mode flip is reversible and
// lossless — the inactive one is saved but inert, edited only by switching back to it.
enum class EnvMode { Staged, Spline };
// The free-drawn alternative to a staged envelope: a contour over NORMALIZED sample time,
// covering the full sample length. Normalized is what makes it length-independent — a
// different-length capture replays the same shape proportionally, with no stored seconds to
// rescale. The default is the smooth y = 1 - x downward slope.
struct SplineEnv {
EnvMode mode = EnvMode::Staged;
VelocityCurve contour = VelocityCurve::rampDown();
};
// GATE = classic held note (AHDSR + sustain loop + note-off release). TRIGGER = one-shot:
// note-off-immune, no sustain loop, plays a % of sample length shaped by the AHD. Both honor
// the start point. Default Gate so an instrument with no params set plays as before.
@@ -150,8 +164,31 @@ struct PlayParams {
// baseRatio_ at note-on — it is fixed for the note's lifetime, so it costs no per-frame work.
VelocityCurve pitchVelocityCurve = VelocityCurve::zero();
FilterParams filter;
// The three drawn contours: the alternative to adsr/trigAhd, to pitchEnv.shape, and to
// filter.env/trigEnv respectively. They sit HERE rather than inside the three envelope
// structs because those are copied whole into the live block, which must stay trivially
// copyable (live_params.h) — and a contour is not a live control anyway: like the velocity
// curves it travels by reload.
SplineEnv ampSpline;
SplineEnv pitchSpline;
SplineEnv filterSpline;
};
// Whether ANY of the three envelopes is drawn rather than staged. Templated over the two
// parameter representations (frames and the editor's seconds mirror) because both spell the
// three fields identically and the rule must not be written twice — compile-time dispatch,
// no runtime cost, off every hot path.
//
// THE consequence, and its one home: a spline contour is a pure time function over the full
// sample length, which IS the Trigger/one-shot playback model — so Gate is not available while
// any spline EG is active. resolvePlay enforces it on the way to the engine; the editor's
// play-mode toggle refuses the Gate segment so the two agree.
template <class Play>
bool splineActive(const Play& p) {
return p.ampSpline.mode == EnvMode::Spline || p.pitchSpline.mode == EnvMode::Spline ||
p.filterSpline.mode == EnvMode::Spline;
}
// [start, end) frames, half-open. A zero-length loop (start == end) is the "no sustain loop"
// marker — a held note past the sample end goes silent rather than looping a zero span.
struct SampleLoop {
+41 -56
View File
@@ -62,9 +62,18 @@ VelocityCurve VelocityCurve::zero() {
return c;
}
VelocityCurve VelocityCurve::rampDown() {
VelocityCurve c;
c.points_ = {{kVelMin, kCurveYMax, false}, {kVelMax, 0.0, false}};
return c;
}
VelocityCurve VelocityCurve::fromPoints(std::vector<VelocityPoint> pts, CurveDomain domain) {
// Stable sort so coincident-X points keep their wire order (eval stays well-defined for
// duplicate-X knots).
// Trim before the endpoint synthesis below can add up to two more, then again after, so a
// corrupt over-long blob lands at exactly the ceiling with its two endpoints intact.
if (pts.size() > kMaxCurvePoints) pts.resize(kMaxCurvePoints);
for (VelocityPoint& p : pts) {
p.velocity = clampVelocity(p.velocity);
p.value = clampValue(p.value, domain);
@@ -77,42 +86,34 @@ VelocityCurve VelocityCurve::fromPoints(std::vector<VelocityPoint> pts, CurveDom
return domain == CurveDomain::Bipolar ? zero() : flat();
}
if (pts.front().velocity > kVelMin) {
pts.insert(pts.begin(), VelocityPoint{kVelMin, pts.front().value});
pts.insert(pts.begin(), VelocityPoint{kVelMin, pts.front().value, pts.front().hard});
} else {
pts.front().velocity = kVelMin;
}
if (pts.back().velocity < kVelMax) {
pts.push_back(VelocityPoint{kVelMax, pts.back().value});
pts.push_back(VelocityPoint{kVelMax, pts.back().value, pts.back().hard});
} else {
pts.back().velocity = kVelMax;
}
if (pts.size() > kMaxCurvePoints) {
// Drop the interior points nearest the end, never an endpoint.
pts.erase(pts.begin() + static_cast<std::ptrdiff_t>(kMaxCurvePoints) - 1,
pts.end() - 1);
}
VelocityCurve c;
c.domain_ = domain;
c.points_ = std::move(pts);
return c;
}
namespace {
// Fritsch-Carlson monotone-cubic tangent: a sign change (or flat) neighbour is a local extremum,
// so the tangent pins to 0 to avoid overshoot; otherwise the weighted-harmonic-mean tangent,
// which for collinear knots (dPrev==dNext) reduces exactly to the shared secant — this is what
// makes the spline reproduce a straight line for linear()-style input.
double fritschCarlsonTangent(double dPrev, double dNext, double spanPrev, double spanNext) {
if (dPrev * dNext <= 0.0) return 0.0;
const double w1 = 2.0 * spanNext + spanPrev;
const double w2 = spanNext + 2.0 * spanPrev;
return (w1 + w2) / (w1 / dPrev + w2 / dNext);
}
} // namespace
double VelocityCurve::eval(double velocity) const {
if (points_.empty()) return curveNeutral(domain_);
if (points_.size() == 1) return clampValue(points_[0].value, domain_);
const double v = clampVelocity(velocity);
if (v <= points_.front().velocity) return clampValue(points_.front().value, domain_);
if (v >= points_.back().velocity) return clampValue(points_.back().value, domain_);
// Linear walk: this overload is the COLD one (a note-on, a paint column). The per-sample
// reader is SplineCursor, which shares the same tangent + Hermite functions.
for (std::size_t i = 0; i + 1 < points_.size(); ++i) {
const VelocityPoint& a = points_[i];
const VelocityPoint& b = points_[i + 1];
@@ -120,55 +121,38 @@ double VelocityCurve::eval(double velocity) const {
const double span = b.velocity - a.velocity;
// Coincident-X neighbours (a step): zero-width segment, no interior to blend.
if (span <= 0.0) return clampValue(b.value, domain_);
// Monotone cubic Hermite (Fritsch-Carlson): provably stays within [a.value, b.value]
// between the two knots (no overshoot), reproducing a straight line for collinear input.
const double d = (b.value - a.value) / span;
double mA = d;
if (i > 0) {
const VelocityPoint& prev = points_[i - 1];
const double spanPrev = a.velocity - prev.velocity;
if (spanPrev > 0.0) {
const double dPrev = (a.value - prev.value) / spanPrev;
mA = fritschCarlsonTangent(dPrev, d, spanPrev, span);
} else {
mA = 0.0;
}
}
double mB = d;
if (i + 2 < points_.size()) {
const VelocityPoint& next = points_[i + 2];
const double spanNext = next.velocity - b.velocity;
if (spanNext > 0.0) {
const double dNext = (next.value - b.value) / spanNext;
mB = fritschCarlsonTangent(d, dNext, span, spanNext);
} else {
mB = 0.0;
}
}
const double t = (v - a.velocity) / span;
const double t2 = t * t;
const double t3 = t2 * t;
const double h00 = 2.0 * t3 - 3.0 * t2 + 1.0;
const double h10 = t3 - 2.0 * t2 + t;
const double h01 = -2.0 * t3 + 3.0 * t2;
const double h11 = t3 - t2;
const double y = h00 * a.value + h10 * span * mA + h01 * b.value + h11 * span * mB;
const SegmentTangents m = segmentTangents(points_.data(), points_.size(), i, d, span);
const double y = hermiteAt(a.value, b.value, span, m.mA, m.mB,
(v - a.velocity) / span);
return clampValue(y, domain_);
}
}
return clampValue(points_.back().value, domain_); // unreachable (v is between the endpoints)
}
std::size_t VelocityCurve::addPoint(double velocity, double value) {
const VelocityPoint p{clampVelocity(velocity), clampValue(value, domain_)};
int VelocityCurve::addPoint(double velocity, double value) {
// At the ceiling the add is REFUSED outright rather than trading a point away — the existing
// contour must come through an over-add bit-identical.
if (points_.size() >= kMaxCurvePoints) return -1;
const VelocityPoint p{clampVelocity(velocity), clampValue(value, domain_), false};
// First index strictly greater, so a duplicate-X point lands immediately after the existing one.
std::size_t i = 0;
while (i < points_.size() && points_[i].velocity <= p.velocity) ++i;
points_.insert(points_.begin() + static_cast<std::ptrdiff_t>(i), p);
return i;
return static_cast<int>(i);
}
bool VelocityCurve::toggleHard(std::size_t index) {
if (index >= points_.size()) return false;
points_[index].hard = !points_[index].hard;
return true;
}
bool VelocityCurve::setHard(std::size_t index, bool hard) {
if (index >= points_.size()) return false;
points_[index].hard = hard;
return true;
}
VelocityPoint VelocityCurve::movePoint(std::size_t index, double velocity, double value) {
@@ -187,7 +171,7 @@ VelocityPoint VelocityCurve::movePoint(std::size_t index, double velocity, doubl
const double hi = points_[index + 1].velocity;
newVel = std::clamp(clampVelocity(velocity), lo, hi);
}
points_[index] = VelocityPoint{newVel, newValue};
points_[index] = VelocityPoint{newVel, newValue, points_[index].hard};
return points_[index];
}
@@ -255,6 +239,7 @@ bool VelocityCurve::equals(const VelocityCurve& other, double eps) const {
for (std::size_t i = 0; i < points_.size(); ++i) {
if (std::fabs(points_[i].velocity - other.points_[i].velocity) > eps) return false;
if (std::fabs(points_[i].value - other.points_[i].value) > eps) return false;
if (points_[i].hard != other.points_[i].hard) return false;
}
return true;
}
+162 -17
View File
@@ -1,21 +1,32 @@
// velocity_curve.h — the velocity->modulation transfer curve, shared by all three
// destinations (amp gain, pitch offset, filter cutoff offset). eval(velocity) is called once
// per note-on in Voice::start(), never per frame. Editor hit-test/inverse-map take an explicit
// pixel Box rather than a Rect: this module sits below sampler_core in the link graph and must
// not gain a transitive dependency on editor-layout types.
// velocity_curve.h — THE monotone spline, shared by every consumer: the three velocity
// transfer curves (amp gain, pitch offset, filter cutoff offset), evaluated once per note-on,
// and the spline EGs, evaluated per voice per sample through SplineCursor. Editor
// hit-test/inverse-map take an explicit pixel Box rather than a Rect: this module sits below
// sampler_core in the link graph and must not gain a dependency on editor-layout types.
#pragma once
#include <cstddef>
#include <cstdint>
#include <vector>
namespace reasampler::instrument::engine {
// The MIDI velocity domain [0,127] — the X span every point clamps into.
inline constexpr double kVelMin = 0.0;
inline constexpr double kVelMax = 127.0;
// The curve's canonical X span. For the three velocity consumers it IS the MIDI velocity
// domain; a spline EG maps normalized sample time onto the same span, which is what lets one
// implementation serve both without a second X domain to keep in sync.
inline constexpr double kCurveXMin = 0.0;
inline constexpr double kCurveXMax = 127.0;
inline constexpr double kVelMin = kCurveXMin; // the velocity consumers' spelling of the span
inline constexpr double kVelMax = kCurveXMax;
inline constexpr double kCurveYMax = 1.0;
// Point-count ceiling. A MUSICAL bound, not a performance one: long rhythmic phrases need the
// resolution, and at roughly two points per articulation event 128 is about four bars of 16ths.
// Segment lookup is logarithmic and the editor's node separation is the real density limit, so
// there is nothing to buy by lowering it. DO NOT LOWER.
inline constexpr std::size_t kMaxCurvePoints = 128;
// The curve's Y range. UNIPOLAR [0,1] is a GAIN — the amp's domain, where the do-nothing
// curve is flat at 1. BIPOLAR [-1,1] is a SIGNED modulation shape — the pitch and filter
// domains, where the do-nothing curve is flat at 0 and the sign picks the direction. A
@@ -34,19 +45,75 @@ constexpr double curveNeutral(CurveDomain d) { return d == CurveDomain::Bipolar
// A raw-constructed point is NOT auto-clamped (the mutators own that invariant) — build curves
// through the named constructors / addPoint rather than pushing raw points.
struct VelocityPoint {
double velocity = 0.0; // X, [0,127]
double velocity = 0.0; // X, over the canonical span
double value = 0.0; // Y, in the owning curve's domain
// A HARD point does no smoothing on either side: it terminates the monotone sub-curve, so
// the two adjacent segments meet at their own natural angle instead of a shared derivative.
// Points are smooth by default; see segmentTangents for the mechanism.
bool hard = false;
};
// Fritsch-Carlson monotone-cubic tangent: a sign change (or flat) neighbour is a local extremum,
// so the tangent pins to 0 to avoid overshoot; otherwise the weighted-harmonic-mean tangent,
// which for collinear knots (dPrev==dNext) reduces exactly to the shared secant — this is what
// makes the spline reproduce a straight line for linear()-style input.
inline double fritschCarlsonTangent(double dPrev, double dNext, double spanPrev, double spanNext) {
if (dPrev * dNext <= 0.0) return 0.0;
const double w1 = 2.0 * spanNext + spanPrev;
const double w2 = spanNext + 2.0 * spanPrev;
return (w1 + w2) / (w1 / dPrev + w2 / dNext);
}
struct SegmentTangents {
double mA = 0.0;
double mB = 0.0;
};
// The Hermite tangents for segment [i, i+1] of an X-ordered point array, where `d` is that
// segment's secant slope and `span` its X width (> 0).
//
// A HARD point is treated exactly as the array's own end is: the tangent there is the segment's
// own secant, so smoothing stops at it. That single rule is the whole hard-point enhancement —
// the contour becomes one or more monotone splines joined at their natural angles, and each
// sub-curve keeps Fritsch-Carlson's no-overshoot guarantee because m == d satisfies its bound.
inline SegmentTangents segmentTangents(const VelocityPoint* p, std::size_t n, std::size_t i,
double d, double span) {
SegmentTangents t{d, d};
if (i > 0 && !p[i].hard) {
const double spanPrev = p[i].velocity - p[i - 1].velocity;
t.mA = (spanPrev > 0.0)
? fritschCarlsonTangent((p[i].value - p[i - 1].value) / spanPrev, d, spanPrev,
span)
: 0.0;
}
if (i + 2 < n && !p[i + 1].hard) {
const double spanNext = p[i + 2].velocity - p[i + 1].velocity;
t.mB = (spanNext > 0.0)
? fritschCarlsonTangent(d, (p[i + 2].value - p[i + 1].value) / spanNext, span,
spanNext)
: 0.0;
}
return t;
}
// The cubic Hermite basis evaluated at t in [0,1] across a segment of width `span`.
inline double hermiteAt(double y0, double y1, double span, double mA, double mB, double t) {
const double t2 = t * t;
const double t3 = t2 * t;
return (2.0 * t3 - 3.0 * t2 + 1.0) * y0 + (t3 - 2.0 * t2 + t) * span * mA +
(-2.0 * t3 + 3.0 * t2) * y1 + (t3 - t2) * span * mB;
}
// Pick radius (px) around a node's drawn point for the editor hit-test.
inline constexpr int kCurveNodeGrabRadius = 6;
// An X-ordered list of control points spanning [0,127], evaluated by a monotone cubic Hermite
// spline (Fritsch-Carlson slope limiting) — a genuine curve, not a polyline, that provably never
// overshoots a segment's value range. For collinear knots the tangents reduce to the secant
// slope, so the spline reproduces linear()'s straight line to within ~1e-15. The two endpoints
// (velocity 0 and 127) are load-bearing: they keep eval total over the domain and are never
// deletable.
// An X-ordered list of control points spanning the canonical X span, evaluated as ONE OR MORE
// monotone cubic Hermite splines (Fritsch-Carlson slope limiting) joined at the hard points — a
// genuine curve, not a polyline, that provably never overshoots any segment's value range. The
// guarantee is PER SEGMENT, so a contour is free to rise and fall. For collinear knots the
// tangents reduce to the secant slope, so the spline reproduces linear()'s straight line to
// within ~1e-15. The two endpoints are load-bearing: they keep eval total over the domain and
// are never deletable.
class VelocityCurve {
public:
// flat() (endpoints (0,1)/(127,1), every velocity -> unity) is the unipolar default — see
@@ -56,6 +123,10 @@ public:
static VelocityCurve linear();
// The bipolar default: flat at 0, so velocity modulates nothing until a curve is drawn.
static VelocityCurve zero();
// y = 1 - x: the smooth downward slope a freshly created spline EG opens on. Two collinear
// knots, so it is straight — and straight is smooth. NOT a change to any velocity curve's
// own default.
static VelocityCurve rampDown();
// Rebuilds from a deserialized point list, repairing the invariant defensively: box-clamps
// each point into `domain`, stable-sorts by velocity, forces both endpoints present
@@ -73,8 +144,15 @@ public:
double eval(double velocity) const;
// Inserted at a velocity duplicating an existing point lands immediately after it, so a
// subsequent move can separate them. Returns the inserted index.
std::size_t addPoint(double velocity, double value);
// subsequent move can separate them. Returns the inserted index, or -1 when the curve is
// already at kMaxCurvePoints — a refusal leaves the contour bit-identical.
int addPoint(double velocity, double value);
// Flips a point between hard and smooth. Out-of-range index is a no-op returning false.
// Permitted on the endpoints, where it changes nothing evaluable: an endpoint's outward
// tangent is already its own secant, which is what hard means.
bool toggleHard(std::size_t index);
bool setHard(std::size_t index, bool hard);
// Box-clamped and X-clamped between immediate neighbours (monotonic-X grammar). The two
// endpoints are pinned in X (only their value moves); out-of-range index is a no-op.
@@ -130,4 +208,71 @@ private:
CurveDomain domain_ = CurveDomain::Unipolar;
};
// The RT read head over a contour: an indexed segment search plus one Hermite evaluation, with
// the segment and its two tangents cached across samples so a monotone read costs one compare.
// Header-inline, branch-only, NO allocation and NO virtual dispatch — it runs per voice per
// sample. A jump (a loop wrap, a fresh note) falls back to a binary search, <= 7 steps at the
// 128-point ceiling.
//
// Holds a RAW POINTER into the bound curve's point array: the caller guarantees the curve
// outlives the cursor. The voice binds against its SampleData, which has exactly that lifetime.
class SplineCursor {
public:
// Binds `c` if it has an evaluable segment; a shorter curve leaves the cursor inactive so
// the caller's `if (active())` skips the whole spline path.
void bind(const VelocityCurve& c) {
const std::vector<VelocityPoint>& pts = c.points();
if (pts.size() < 2) { clear(); return; }
pts_ = pts.data();
n_ = pts.size();
select(0);
}
void clear() { pts_ = nullptr; n_ = 0; }
bool active() const { return n_ >= 2; }
// `phase` is normalized position over the contour's whole span, [0,1]; out-of-range clamps
// to the terminal values (a note past its span holds the contour's last level).
double eval(double phase) {
const double x = (phase <= 0.0) ? kCurveXMin
: (phase >= 1.0) ? kCurveXMax
: kCurveXMin + phase * (kCurveXMax - kCurveXMin);
if (x <= x0_ && seg_ == 0) return y0_;
if (x >= x1_ && seg_ + 2 == n_) return y1_;
if (x < x0_ || x > x1_) locate(x);
if (span_ <= 0.0) return y1_; // coincident-X knots: a step, no interior to blend
return hermiteAt(y0_, y1_, span_, mA_, mB_, (x - x0_) / span_);
}
private:
// The common case is the next segment (a monotone read walking forward); anything else is a
// binary search over the X-ordered array.
void locate(double x) {
if (x > x1_ && seg_ + 2 < n_ && x <= pts_[seg_ + 2].velocity) { select(seg_ + 1); return; }
std::size_t lo = 0, hi = n_ - 2;
while (lo < hi) {
const std::size_t mid = lo + (hi - lo + 1) / 2;
if (pts_[mid].velocity <= x) lo = mid; else hi = mid - 1;
}
select(lo);
}
void select(std::size_t i) {
seg_ = i;
x0_ = pts_[i].velocity;
x1_ = pts_[i + 1].velocity;
y0_ = pts_[i].value;
y1_ = pts_[i + 1].value;
span_ = x1_ - x0_;
const SegmentTangents t =
segmentTangents(pts_, n_, i, span_ > 0.0 ? (y1_ - y0_) / span_ : 0.0, span_);
mA_ = t.mA;
mB_ = t.mB;
}
const VelocityPoint* pts_ = nullptr;
std::size_t n_ = 0;
std::size_t seg_ = 0;
double x0_ = 0.0, x1_ = 0.0, y0_ = 0.0, y1_ = 0.0, span_ = 0.0, mA_ = 0.0, mB_ = 0.0;
};
} // namespace reasampler::instrument::engine
+20
View File
@@ -72,6 +72,26 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
loop_ = instrument::engine::loop::resolveLoop(sample.loop, sample.loopCrossfadeFrames,
frameCount, playMode_ == PlayMode::Gate);
// Bind whichever EGs are drawn. Rebound on EVERY note-on rather than cached: a reload hands
// the engine a fresh SampleData, so a stale pointer into the previous one is the bug this
// avoids. A Staged EG clears its cursor, which is what keeps the per-sample path off the
// spline branch entirely.
splineScale_ = frameCount > 0 ? 1.0 / static_cast<double>(frameCount) : 0.0;
if (p.ampSpline.mode == EnvMode::Spline) ampSplineCur_.bind(p.ampSpline.contour);
else ampSplineCur_.clear();
if (p.pitchEnv.enabled && p.pitchSpline.mode == EnvMode::Spline) {
pitchSplineCur_.bind(p.pitchSpline.contour);
pitchSplineDepth_ = p.pitchEnv.peakSemitones;
} else {
pitchSplineCur_.clear();
pitchSplineDepth_ = 0.0;
}
if (p.filter.enabled && p.filterSpline.mode == EnvMode::Spline) {
filterSplineCur_.bind(p.filterSpline.contour);
} else {
filterSplineCur_.clear();
}
// Amplitude envelope: Gate = AHDSR (all five fields read from play.adsr, resolved to
// frames from stored seconds at load time); Trigger = the staged AHD over the % play span.
const std::int64_t postStart = frameCount - start; // >= 1 (start clamped < frameCount)
+33 -7
View File
@@ -25,6 +25,7 @@ namespace reasampler {
using audio::AudioSample;
using instrument::engine::PitchShifter;
using instrument::engine::SplineCursor;
using instrument::engine::VelocityCurve;
using instrument::engine::VelocityPoint;
using instrument::engine::loop::ResolvedLoop;
@@ -164,14 +165,24 @@ public:
}
private:
// This frame's amplitude in [0,1] from the active envelope. Gate: AHDSR ticks once per
// output frame (envelope time is wall-clock, independent of read rate). Trigger: the AHD
// The read head as a fraction of the whole sample — the domain every spline EG is a pure
// function of. Zero-length sample leaves splineScale_ at 0, which parks every contour on
// its opening value.
double splinePhase() const { return readPos_ * splineScale_; }
// This frame's amplitude in [0,1] from the active envelope. Spline: the drawn contour read
// at the normalized position (one cached-segment compare per frame). Gate: AHDSR ticks once
// per output frame (envelope time is wall-clock, independent of read rate). Trigger: the AHD
// is evaluated at the source offset (readPos - startFrame) so its stages anchor to source
// frames regardless of pitch engine. Sets amplitudeDone_ on finish so advanceFrame frees
// the voice.
double tickAmplitude() {
double amp;
if (playMode_ == PlayMode::Gate) {
if (ampSplineCur_.active()) {
// A contour covers the sample end to end, so the head leaving the span IS the end of
// the note — the exhaustion path in advanceFrame is what frees the voice.
amp = ampSplineCur_.eval(splinePhase());
} else if (playMode_ == PlayMode::Gate) {
amp = env_.tick();
if (env_.finished()) amplitudeDone_ = true;
} else {
@@ -202,9 +213,11 @@ private:
// The filter envelope takes the amp's shape under the active mode — AHDSR in Gate,
// the source-offset AHD in Trigger. playMode_ is fixed for the note's lifetime, so the
// branch is perfectly predicted.
const double envOut = (playMode_ == PlayMode::Gate)
? filterEnv_.tick()
: filterAhd_.amplitudeAt(sourceOffset());
const double envOut = filterSplineCur_.active()
? filterSplineCur_.eval(splinePhase())
: ((playMode_ == PlayMode::Gate)
? filterEnv_.tick()
: filterAhd_.amplitudeAt(sourceOffset()));
double cut = static_cast<double>(filterBaseCutoff_) + filterModAmount_ * envOut;
if (cut < 0.0) cut = 0.0;
if (cut > 1.0) cut = 1.0;
@@ -394,7 +407,9 @@ private:
seedTerminalDeclick();
}
const double gain = amp * velocityGain_;
const double pitchEnvSemis = pitchEnv_.tick();
const double pitchEnvSemis = pitchSplineCur_.active()
? pitchSplineDepth_ * pitchSplineCur_.eval(splinePhase())
: pitchEnv_.tick();
// 2^(semis/12); when the envelope is off (semis exactly 0) this is 1.0 and skips the
// pow entirely — no per-frame transcendental on the common path.
@@ -585,6 +600,17 @@ private:
std::int64_t playEnd_ = 0; // Trigger: source-frame end; Gate: unused
bool amplitudeDone_ = false; // set when the active amplitude envelope finished
// The three drawn contours, bound at note-on to the loaded capture's own point arrays (the
// SampleData outlives the voice — same contract as sample_). A Staged EG leaves its cursor
// inactive, so a purely staged instrument's per-sample path gains three predicted branches
// and nothing else. splineScale_ is 1/frameCount, the readPos -> [0,1] map every contour
// shares; pitchSplineDepth_ is the pitch envelope's peak, zero while it is disabled.
SplineCursor ampSplineCur_;
SplineCursor pitchSplineCur_;
SplineCursor filterSplineCur_;
double splineScale_ = 0.0;
double pitchSplineDepth_ = 0.0;
// The sustain loop folded ONCE at note-on: the sample, the play mode and the stored span
// are all fixed for the note's lifetime, so re-deriving validity per frame bought nothing.
// Shared by the output anchor, the Preserve feed, and the start()-time ring prime.
+22 -2
View File
@@ -8,7 +8,7 @@
// own links are velocity_curve + master_gain (wire value validation), never the engine.
//
// EVERY wire format below is FROZEN; the full version ladders (envelope v1..v11, params
// payload v1..v12) must be preserved exactly. This header is the ONE home for both ladders
// payload v1..v13) must be preserved exactly. This header is the ONE home for both ladders
// and every version constant; the payload half is IMPLEMENTED in params_payload.
#include <cstdint>
@@ -104,6 +104,22 @@ namespace reasampler::instrument::map {
// which transposes nothing. A DOWNGRADE to a pre-v12 binary re-narrows the domain, so a curve
// drawn into the negative half comes back with that half clamped to 0.
//
// v13 (CURRENT WRITE FORMAT) is v12 PLUS the DUAL Staged/Spline envelope state, appended after
// the velocity->pitch curve. Its two halves, in order:
// (a) the three spline EGs — amp, pitch, filter, in that order. Each: 1 byte mode (0 Staged /
// 1 Spline), then a SPLINE CURVE block: 4-byte LE point count N, then per point 8-byte LE
// x + 8-byte LE y (doubles) + 1 byte hard. x spans the curve's canonical [0,127] (a
// normalized-time contour maps onto that same span — velocity_curve.h owns why one span
// serves both), y is UNIPOLAR [0,1]; the pitch and filter depth knobs scale it.
// (b) the HARD-FLAG tails for the three v7/v9/v12 velocity curves — amp, filter, pitch, in
// that order. Each: 4-byte LE count N, then N bytes. Those three curve blocks are FROZEN
// at 16 bytes/point and cannot grow a per-point flag, so the flags ride here instead. A
// tail whose count does not match the curve as read is IGNORED (the curve keeps its
// flags-off default) rather than applied to the wrong knots — a repaired blob loses the
// hard points, never misplaces them.
// A v12-or-older blob is a strict prefix and lifts to {Staged, the y = 1 - x default contour}
// on all three EGs with no hard point anywhere, so it plays exactly as it did.
//
// The two int64 slots the v5 play tail spends on the RETIRED Trigger fade pair are frozen in
// shape and still read: a pre-v10 blob's fade-in/fade-out become the Trigger AHD that replaced
// them (attack <- fade-in, decay <- fade-out, hold <- the whole remainder), converted to
@@ -135,7 +151,7 @@ inline constexpr std::uint32_t kPerformanceStateVersion = 2;
// The params-payload format version and its detection marker. The marker is a high sentinel
// no legitimate v1 zone count (bounded by 128 MIDI zones, always tiny) could ever equal, so
// a reader detects record shape independent of the envelope version.
inline constexpr std::uint32_t kParamsPayloadVersion = 12; // v11 + the velocity->pitch curve
inline constexpr std::uint32_t kParamsPayloadVersion = 13; // v12 + the dual Staged/Spline state
inline constexpr std::uint32_t kParamsFormatMarker = 0xFFFFFF00u;
// The first SINGLE-RECORD payload version. Everything below it is a retired zone list and
@@ -159,6 +175,10 @@ inline constexpr std::uint32_t kParamsLoopVersion = 11;
// pre-v12 curve's y values are already valid bipolar ones.
inline constexpr std::uint32_t kParamsVelocityVersion = 12;
// v12 + the dual Staged/Spline state; the appended tail branches on THIS, never on
// kParamsPayloadVersion.
inline constexpr std::uint32_t kParamsSplineVersion = 13;
// (No nominal-rate constant.) The legacy v3 payload's wall-clock frame counts convert to
// seconds at the v3 read boundary using the PROJECT sample rate threaded in as a parameter
// (frames / projectRate = seconds) — the same rate the build already receives, so the
@@ -50,6 +50,27 @@ void putCurve(std::vector<std::uint8_t>& out, const VelocityCurve& curve) {
}
}
// A spline EG: 1 byte mode, then the contour as count + (x, y, hard) per point. Distinct from
// putCurve because the three velocity-curve blocks are frozen at 16 bytes/point and cannot grow
// the hard flag; this block was born with it.
void putSplineEnv(std::vector<std::uint8_t>& out, const SplineEnv& s) {
out.push_back(s.mode == EnvMode::Spline ? 1 : 0);
const std::vector<VelocityPoint>& pts = s.contour.points();
putLE(out, static_cast<std::uint32_t>(pts.size()));
for (const VelocityPoint& pt : pts) {
putLE(out, doubleToBits(pt.velocity));
putLE(out, doubleToBits(pt.value));
out.push_back(pt.hard ? 1 : 0);
}
}
// The hard flags of an already-written velocity curve: count + one byte per point.
void putHardFlags(std::vector<std::uint8_t>& out, const VelocityCurve& curve) {
const std::vector<VelocityPoint>& pts = curve.points();
putLE(out, static_cast<std::uint32_t>(pts.size()));
for (const VelocityPoint& pt : pts) out.push_back(pt.hard ? 1 : 0);
}
// A stored AHD's five doubles, in one order shared by every AHD on the wire.
void putAhd(std::vector<std::uint8_t>& out, const AhdSeconds& a) {
putLE(out, doubleToBits(a.attackSeconds));
@@ -114,6 +135,41 @@ void readCurveTail(ByteReader& r, VelocityCurve& curve,
}
}
// Read a spline EG. A truncated read leaves `s` at its Staged/default-contour construction
// value, which is what makes a pre-v13 blob play exactly as it did.
void readSplineEnv(ByteReader& r, SplineEnv& s) {
const bool spline = (r.u8() != 0);
const std::uint32_t ptCount = r.u32();
std::vector<VelocityPoint> pts;
// Bound the reserve to what the blob can hold (17 bytes/point) so a corrupt huge count
// can't trigger a giant allocation before the bounded reads fail.
const std::size_t remaining = r.bytes.size() > r.pos ? r.bytes.size() - r.pos : 0;
pts.reserve(std::min(static_cast<std::size_t>(ptCount), remaining / 17));
for (std::uint32_t i = 0; i < ptCount && r.ok; ++i) {
const double x = bitsToDouble(r.u64());
const double y = bitsToDouble(r.u64());
const bool hard = (r.u8() != 0);
pts.push_back(VelocityPoint{x, y, hard});
}
if (!r.ok) return;
s.mode = spline ? EnvMode::Spline : EnvMode::Staged;
s.contour = VelocityCurve::fromPoints(std::move(pts),
reasampler::instrument::engine::CurveDomain::Unipolar);
}
// Apply a hard-flag tail to an already-read velocity curve. A count that disagrees with the
// curve fromPoints actually produced is dropped rather than applied to shifted knots.
void readHardFlags(ByteReader& r, VelocityCurve& curve) {
const std::uint32_t count = r.u32();
const std::size_t remaining = r.bytes.size() > r.pos ? r.bytes.size() - r.pos : 0;
if (count > remaining) { r.ok = false; return; }
std::vector<std::uint8_t> flags;
flags.reserve(count);
for (std::uint32_t i = 0; i < count && r.ok; ++i) flags.push_back(r.u8());
if (!r.ok || flags.size() != curve.size()) return;
for (std::size_t i = 0; i < flags.size(); ++i) curve.setHard(i, flags[i] != 0);
}
// Read the v9 filter tail into `p`. A blob that stops short leaves the off/neutral default,
// which is what makes a v8 blob play bit-identically under the new codec. The curve reads as
// bipolar at EVERY version — a pre-v12 blob's y values are already valid bipolar ones, so its
@@ -331,6 +387,14 @@ void putParamsPayload(std::vector<std::uint8_t>& out, const InstrumentParams& p)
putLE(out, asU64(p.loopCrossfadeFrames));
// v12: the velocity->pitch curve.
putCurve(out, pp.pitchVelocityCurve);
// v13: the dual Staged/Spline state — the three contours, then the hard flags the three
// frozen velocity-curve blocks above had no room for.
putSplineEnv(out, pp.ampSpline);
putSplineEnv(out, pp.pitchSpline);
putSplineEnv(out, pp.filterSpline);
putHardFlags(out, p.velocityCurve);
putHardFlags(out, f.velocityCurve);
putHardFlags(out, pp.pitchVelocityCurve);
}
// Read whichever payload shape follows: the single-record shape (v8 onward, growing by
@@ -373,6 +437,14 @@ PayloadRead readParamsPayload(ByteReader& r, double projectRate) {
readCurveTail(r, p.play.pitchVelocityCurve,
reasampler::instrument::engine::CurveDomain::Bipolar);
}
if (pv >= kParamsSplineVersion) {
readSplineEnv(r, p.play.ampSpline);
readSplineEnv(r, p.play.pitchSpline);
readSplineEnv(r, p.play.filterSpline);
readHardFlags(r, p.velocityCurve);
readHardFlags(r, p.play.filter.velocityCurve);
readHardFlags(r, p.play.pitchVelocityCurve);
}
// A truncated record leaves whatever parsed plus construction defaults for the rest —
// the same degrade-don't-throw contract the zone ladder always had.
if (!r.ok) return PayloadRead{};
+10
View File
@@ -256,6 +256,16 @@ PlayParams resolvePlay(const PlaySeconds& stored, int sampleRate) {
out.filter.env.decayCurve = stored.filter.env.decayCurve;
out.filter.env.releaseCurve = stored.filter.env.releaseCurve;
out.filter.trigEnv = resolveAhd(stored.filter.trigEnv);
// The three drawn contours are normalized over the sample's own length, so no rate resolves
// them — they carry through verbatim, which is also what makes a different-length capture
// replay the same shape proportionally.
out.ampSpline = stored.ampSpline;
out.pitchSpline = stored.pitchSpline;
out.filterSpline = stored.filterSpline;
// Gate is unavailable while any EG is drawn — see splineActive (play_params.h) for why.
// The editor refuses the Gate segment for the same reason; enforcing it HERE as well is
// what keeps a hand-edited or downgraded blob from reaching the engine as Gate + spline.
if (splineActive(stored)) out.playMode = PlayMode::Trigger;
return out;
}
+6
View File
@@ -202,6 +202,12 @@ struct PlaySeconds {
PitchEnvSeconds pitchEnv; // AHD pitch modulation, off by default
VelocityCurve pitchVelocityCurve = VelocityCurve::zero(); // velocity -> pitch, off by default
FilterSeconds filter; // per-voice filter, off by default
// The three drawn contours, in the same slots the engine bundle carries them (play_params.h
// owns why they sit beside the envelopes rather than inside them). Normalized over the
// sample's own length, so resolvePlay needs no rate for them.
SplineEnv ampSpline;
SplineEnv pitchSpline;
SplineEnv filterSpline;
};
// Resolve a stored seconds bundle to the engine's frame-domain PlayParams against a live
+7
View File
@@ -56,6 +56,13 @@ reasampler_pure_library(deck_groups
# needs the band allocator deck_groups itself has no reason to depend on.
reasampler_test(deck_groups LINK deck_groups sample_bands)
# The point-editing grammar both spline consumers share, so it links the curve itself (unlike
# envelope_overlay/envelope_edit, which stay engine-free the staged envelopes touch no curve).
reasampler_pure_library(spline_edit
SOURCES spline_edit.cpp
LINK PUBLIC editor_geometry velocity_curve)
reasampler_test(spline_edit LINK spline_edit)
reasampler_pure_library(curve_popup SOURCES curve_popup.cpp LINK PUBLIC editor_geometry)
# velocity_curve is linked for the test only: the sheet's geometry is domain-agnostic, and
# proving that takes a curve of each domain mapped through the one curveBox.
+63 -14
View File
@@ -9,6 +9,11 @@ namespace reasampler::instrument::ui {
namespace {
int id(DeckParam p) { return static_cast<int>(p); }
double clamp(double v, double lo, double hi) { return v < lo ? lo : (v > hi ? hi : v); }
// Segment width of the three Staged|Spline toggles. Sized so each env group's caption row stays
// no wider than its knob row — the ceiling is PITCH ENV's, whose caption row lands exactly on
// its four-cell knob row at 23. Raising it reflows the deck's first row.
constexpr int kEnvModeSegW = 23;
} // namespace
double deckBipolarFromNorm(double norm) { return clamp(norm, 0.0, 1.0) * 2.0 - 1.0; }
@@ -31,6 +36,10 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
penv.captionWidth = 58;
penv.captionRadio = {id(DeckParam::kPitchEnvSelect)};
penv.captionToggle = {id(DeckParam::kPitchEnvEnable), 32};
// The mode toggle rides the caption slack rather than the knob row: every env group's
// knob row is wider than its caption row, so this costs no group width — and the deck
// has six pixels of headroom on its first row at the editor's floor width.
penv.captionToggle2 = {id(DeckParam::kPitchEnvMode), kEnvModeSegW};
penv.cellIds = {id(DeckParam::kPitchEnvAttack),
id(DeckParam::kPitchEnvHold),
id(DeckParam::kPitchEnvDecay),
@@ -58,6 +67,7 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
fenv.id = kGroupFilterEnv;
fenv.captionWidth = 66;
fenv.captionRadio = {id(DeckParam::kFilterEnvSelect)};
fenv.captionToggle2 = {id(DeckParam::kFilterEnvMode), kEnvModeSegW};
if (trigger) {
fenv.cellIds = {id(DeckParam::kFilterTrigAttack), id(DeckParam::kFilterTrigHold),
id(DeckParam::kFilterTrigDecay), -1, -1};
@@ -76,6 +86,7 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
amp.captionWidth = 78;
amp.captionRadio = {id(DeckParam::kAmpEnvSelect)};
amp.captionToggle = {id(DeckParam::kPlayMode), 44};
amp.captionToggle2 = {id(DeckParam::kAmpEnvMode), kEnvModeSegW};
if (trigger) {
// The play span first, then the AHD that shapes it, time-ordered left-to-right so
// the row reads like the drawn envelope. One blank keeps the group's width — and
@@ -204,6 +215,11 @@ bool isLiveDeckParam(DeckParam id) {
case DeckParam::kAmpEnvSelect:
case DeckParam::kPitchEnvSelect:
case DeckParam::kFilterEnvSelect:
// A mode toggle names a different envelope, not a different setting of one — the same
// reason every other discrete toggle above is excluded.
case DeckParam::kAmpEnvMode:
case DeckParam::kPitchEnvMode:
case DeckParam::kFilterEnvMode:
case DeckParam::kVoiceCount:
case DeckParam::kVoiceMode:
case DeckParam::kMonoTrigger:
@@ -229,24 +245,65 @@ OverlayEnv nextOverlaySelection(OverlayEnv current, int radioId) {
return (current == picked) ? OverlayEnv::kNone : picked;
}
bool overlayEnvInert(OverlayEnv env, bool pitchEnvEnabled, bool filterEnabled) {
OverlayEnv overlayEnvForModeToggle(int toggleId) {
switch (static_cast<DeckParam>(toggleId)) {
case DeckParam::kAmpEnvMode: return OverlayEnv::kAmp;
case DeckParam::kPitchEnvMode: return OverlayEnv::kPitch;
case DeckParam::kFilterEnvMode: return OverlayEnv::kFilter;
default: return OverlayEnv::kNone;
}
}
bool overlayEnvEnabled(OverlayEnv env, const DeckEnableState& state) {
switch (env) {
case OverlayEnv::kPitch: return !pitchEnvEnabled;
case OverlayEnv::kFilter: return !filterEnabled;
case OverlayEnv::kPitch: return state.pitchEnvEnabled;
case OverlayEnv::kFilter: return state.filterEnabled;
case OverlayEnv::kAmp:
case OverlayEnv::kNone:
return true;
}
return true; // unreachable for a valid enumerator; silences a warning.
}
bool overlayEnvInert(OverlayEnv env, const DeckEnableState& state) {
if (env == OverlayEnv::kNone) return false;
if (!overlayEnvEnabled(env, state)) return true;
switch (env) {
case OverlayEnv::kPitch: return state.pitchSpline;
case OverlayEnv::kFilter: return state.filterSpline;
case OverlayEnv::kAmp: return state.ampSpline;
case OverlayEnv::kNone:
return false;
}
return false; // unreachable for a valid enumerator; silences a warning.
}
bool deckKnobInert(DeckParam id, bool pitchEnvEnabled, bool filterEnabled) {
bool deckKnobInert(DeckParam id, const DeckEnableState& state) {
switch (id) {
case DeckParam::kAttack:
case DeckParam::kHold:
case DeckParam::kDecay:
case DeckParam::kSustain:
case DeckParam::kRelease:
case DeckParam::kTrigAttack:
case DeckParam::kTrigHold:
case DeckParam::kTrigDecay:
return state.ampSpline;
case DeckParam::kPitchEnvAttack:
case DeckParam::kPitchEnvHold:
case DeckParam::kPitchEnvDecay:
return !state.pitchEnvEnabled || state.pitchSpline;
case DeckParam::kPitchEnvDepth:
return !pitchEnvEnabled;
return !state.pitchEnvEnabled;
case DeckParam::kFilterEnvAttack:
case DeckParam::kFilterEnvHold:
case DeckParam::kFilterEnvDecay:
case DeckParam::kFilterEnvSustain:
case DeckParam::kFilterEnvRelease:
case DeckParam::kFilterTrigAttack:
case DeckParam::kFilterTrigHold:
case DeckParam::kFilterTrigDecay:
return !state.filterEnabled || state.filterSpline;
case DeckParam::kFilterMorph:
case DeckParam::kFilterCutoff:
case DeckParam::kFilterQ:
@@ -257,15 +314,7 @@ bool deckKnobInert(DeckParam id, bool pitchEnvEnabled, bool filterEnabled) {
// The filter's velocity curve sits in the VELOCITY group but is a filter parameter:
// it goes inert with every other one, so no surface can reach a param the knobs can't.
case DeckParam::kFilterVelCurve:
case DeckParam::kFilterEnvAttack:
case DeckParam::kFilterEnvHold:
case DeckParam::kFilterEnvDecay:
case DeckParam::kFilterEnvSustain:
case DeckParam::kFilterEnvRelease:
case DeckParam::kFilterTrigAttack:
case DeckParam::kFilterTrigHold:
case DeckParam::kFilterTrigDecay:
return !filterEnabled;
return !state.filterEnabled;
default:
return false;
}
+34 -8
View File
@@ -74,6 +74,11 @@ enum class DeckParam {
kAmpEnvSelect,
kPitchEnvSelect,
kFilterEnvSelect,
// Staged | Spline mode per envelope. Both states persist either way (play_params.h's
// SplineEnv); this only picks which one plays and which one the overlay edits.
kAmpEnvMode,
kPitchEnvMode,
kFilterEnvMode,
// Deck-only controls: processor-side per-instance params — routed to the processor
// setters, never through the parameter set.
kVoiceCount, // polyphony bound (1..32) — a stepped knob in the VOICE group
@@ -163,17 +168,38 @@ OverlayEnv overlayEnvForRadio(int radioId);
// 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 group states the two inert predicates below read. One struct rather than a growing
// parameter list, so adding a gate is a change at the two predicates and nowhere else.
struct DeckEnableState {
bool pitchEnvEnabled = false;
bool filterEnabled = false;
bool ampSpline = false; // the amp EG is drawn rather than staged
bool pitchSpline = false;
bool filterSpline = false;
};
// Which envelope a Staged|Spline mode toggle belongs to; kNone for any other control id.
OverlayEnv overlayEnvForModeToggle(int toggleId);
// Whether `env`'s deck group is switched on at all. Amp has no enable toggle and is always on.
// The gate BOTH overlay modes share — a disabled group's contour is as dead as its knobs.
bool overlayEnvEnabled(OverlayEnv env, const DeckEnableState& state);
// Whether the STAGED overlay for `env` is INERT: its deck group 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). An envelope in SPLINE mode is inert here too: the
// staged nodes are not what the overlay is editing.
bool overlayEnvInert(OverlayEnv env, const DeckEnableState& state);
// Whether a deck knob cell is drawn-but-dead: the pitch envelope's four knobs while it is
// disabled, and the filter group's tone/modulation knobs (plus its VELOCITY cell, a filter
// parameter that just sits in that group) while the filter is disabled. Every other id is
// always live. Mirrors overlayEnvInert's group-toggle-gates-its-knobs shape for the deck's own
// disabled, the filter group's tone/modulation knobs (plus its VELOCITY cell, a filter
// parameter that just sits in that group) while the filter is disabled, and every STAGED
// SEGMENT knob of an envelope switched to Spline. The segment knobs' inner curve dials go with
// them — the dial is reached through its outer cell, so one predicate covers both. The DEPTH
// knobs (pitch peak, filter mod amount) stay live in either mode: they scale whichever shape is
// active rather than describing a stage. Mirrors overlayEnvInert's shape for the deck's own
// mouse-down/paint (the shell's deckKnobDisabled is a thin int-id wrapper over this).
bool deckKnobInert(DeckParam id, bool pitchEnvEnabled, bool filterEnabled);
bool deckKnobInert(DeckParam id, const DeckEnableState& state);
// 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
+16 -12
View File
@@ -24,6 +24,7 @@ int knobRowWidth(const DeckGroupDesc& g) {
int captionRowWidth(const DeckGroupDesc& g) {
int w = g.captionWidth;
if (g.captionToggle.id >= 0) w += kDeckToggleGap + 2 * g.captionToggle.segWidth;
if (g.captionToggle2.id >= 0) w += kDeckToggleGap + 2 * g.captionToggle2.segWidth;
if (g.captionRadio.id >= 0) w += kDeckToggleGap + kDeckRadioSize;
return w;
}
@@ -49,16 +50,20 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) {
captionRight = out.captionRadio.box.x - kDeckToggleGap;
out.caption.width = captionRight - out.caption.x;
}
if (g.captionToggle.id >= 0) {
const int segW = g.captionToggle.segWidth;
const int togTop = captionTop + (kDeckCaptionH - kDeckToggleH) / 2;
const int togTop = captionTop + (kDeckCaptionH - kDeckToggleH) / 2;
const auto placeToggle = [&](const DeckToggleDesc& d, DeckToggleLayout& into) {
if (d.id < 0) return;
const int segW = d.segWidth;
const Rect seg1 = Rect::ltrb(captionRight - segW, togTop, captionRight,
togTop + kDeckToggleH);
const Rect seg0 = Rect::ltrb(seg1.x - segW, togTop, seg1.x, togTop + kDeckToggleH);
out.captionToggle = DeckToggleLayout{g.captionToggle.id, seg0, seg1};
// Caption text stops at the toggle: pull the right edge in (XYWH: shrink width).
out.caption.width = (seg0.x - kDeckToggleGap) - out.caption.x;
}
into = DeckToggleLayout{d.id, seg0, seg1};
captionRight = seg0.x - kDeckToggleGap;
// Caption text stops at the leftmost toggle: pull the right edge in (XYWH: width).
out.caption.width = captionRight - out.caption.x;
};
placeToggle(g.captionToggle, out.captionToggle);
placeToggle(g.captionToggle2, out.captionToggle2);
// Knob row: fixed cells left-to-right, then the optional row toggle.
const int cellTop = captionTop + kDeckCaptionH + kDeckCaptionGap;
@@ -151,11 +156,10 @@ DeckHit hitTestDeck(const DeckLayout& layout, int x, int y) {
if (g.captionRadio.id >= 0 && contains(g.captionRadio.box, x, y)) {
return {DeckHitKind::CaptionRadio, g.captionRadio.id, -1, false};
}
if (g.captionToggle.id >= 0) {
if (contains(g.captionToggle.seg0, x, y))
return {DeckHitKind::CaptionToggle, g.captionToggle.id, 0};
if (contains(g.captionToggle.seg1, x, y))
return {DeckHitKind::CaptionToggle, g.captionToggle.id, 1};
for (const DeckToggleLayout* t : {&g.captionToggle, &g.captionToggle2}) {
if (t->id < 0) continue;
if (contains(t->seg0, x, y)) return {DeckHitKind::CaptionToggle, t->id, 0};
if (contains(t->seg1, x, y)) return {DeckHitKind::CaptionToggle, t->id, 1};
}
if (g.rowToggle.id >= 0) {
if (contains(g.rowToggle.seg0, x, y))
+6
View File
@@ -64,6 +64,11 @@ struct DeckGroupDesc {
int captionWidth = 60;
DeckRadioDesc captionRadio; // the caption row's far corner; id -1 = none
DeckToggleDesc captionToggle; // caption row, left of the radio; id -1 = none
// A second caption toggle, placed immediately left of the first (or in its place when the
// first is absent). Exists because a group whose knob row is wider than its caption row has
// caption slack a toggle can occupy for free — a rowToggle would widen the GROUP, and the
// deck has six pixels of headroom on its first row at the editor's floor width.
DeckToggleDesc captionToggle2;
std::vector<int> cellIds; // knob cells; -1 = blank reserve
DeckToggleDesc rowToggle; // in the knob row after the cells; id -1 = none
};
@@ -95,6 +100,7 @@ struct DeckGroupLayout {
Rect caption; // caption text rect (left part of the caption row)
DeckRadioLayout captionRadio; // id -1 when absent (rect empty)
DeckToggleLayout captionToggle; // id -1 when absent (rects empty)
DeckToggleLayout captionToggle2;
std::vector<DeckCellLayout> cells;
DeckToggleLayout rowToggle; // id -1 when absent
};
+29
View File
@@ -0,0 +1,29 @@
// spline_edit.cpp — see spline_edit.h. Pure decision logic; no host types.
#include "core/instrument/ui/spline_edit.h"
namespace reasampler::instrument::ui {
SplineEdit resolveSplineEdit(const VelocityCurve& curve, const VelocityCurve::Box& box,
SplineGesture gesture, int x, int y) {
if (box.width <= 0 || box.height <= 1) return {};
const int idx = curve.pointAtPixel(box, x, y);
switch (gesture) {
case SplineGesture::kRight:
return idx >= 0 ? SplineEdit{SplineEditKind::kDelete, idx} : SplineEdit{};
case SplineGesture::kControlLeft:
return idx >= 0 ? SplineEdit{SplineEditKind::kToggleHard, idx} : SplineEdit{};
case SplineGesture::kLeft:
break;
}
if (idx >= 0) return {SplineEditKind::kGrab, idx};
const bool inBox = (x >= box.left && x < box.left + box.width && y >= box.top &&
y < box.top + box.height);
return inBox ? SplineEdit{SplineEditKind::kAdd, -1} : SplineEdit{};
}
VelocityCurve::Box splineOverlayBox(const OverlayArea& area) {
return VelocityCurve::Box{area.rect.x, area.rect.y, area.rect.width, area.rect.height};
}
} // namespace reasampler::instrument::ui
+42
View File
@@ -0,0 +1,42 @@
// spline_edit.h — THE point-editing grammar, and the one place it is written down. Both spline
// consumers route their mouse-down through it — the velocity-curve popup and the spline EG
// overlay — so the two cannot drift into two grammars. Mirror of envelope_edit: decision logic
// only, no host types, no drawing.
#pragma once
#include "core/instrument/engine/velocity_curve.h"
#include "core/instrument/ui/editor_geometry.h" // Rect / OverlayArea
namespace reasampler::instrument::ui {
using engine::VelocityCurve;
// The gesture, in the pure module's own vocabulary (the shell maps its modifier state onto it).
enum class SplineGesture { kLeft, kRight, kControlLeft };
// What the gesture resolves to. Left-click adds a point in empty space and grabs an existing
// one; right-click deletes; control-click toggles hard/smooth. Points are smooth by default.
enum class SplineEditKind { kNone, kGrab, kAdd, kDelete, kToggleHard };
struct SplineEdit {
SplineEditKind kind = SplineEditKind::kNone;
int index = -1; // the point the action targets; -1 for kAdd (it has none yet) and kNone
};
// Resolves a click at (x, y) over `box` into an edit. The endpoint and point-count rules are
// NOT re-stated here — kDelete on an endpoint and kAdd at the ceiling are refused by
// VelocityCurve::deletePoint / addPoint, which the caller applies, so there is exactly one home
// for each. A click outside the mapping box resolves to kNone unless it lands on a node's pick
// radius: the drawn inset ring must not ADD (the new point would clamp onto an endpoint's x and
// stack an undeletable duplicate) but must still be able to grab.
SplineEdit resolveSplineEdit(const VelocityCurve& curve, const VelocityCurve::Box& box,
SplineGesture gesture, int x, int y);
// The contour's mapping box inside the waveform overlay: the FULL area, so the drawn contour
// spans the whole sample width 1:1 with its time axis. No inset — unlike the popup's box, which
// insets to keep endpoint handles clear of the sheet border, this one must stay 1:1 with the
// waveform beneath it. Takes the overlay (not a lane) — see waveform_view.h's overlay contract.
VelocityCurve::Box splineOverlayBox(const OverlayArea& area);
} // namespace reasampler::instrument::ui