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:
@@ -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 (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.
|
||||
- `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 Fritsch–Carlson 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 Fritsch–Carlson 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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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{};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user