diff --git a/src/core/instrument/CLAUDE.md b/src/core/instrument/CLAUDE.md index 71beece..f0782f3 100644 --- a/src/core/instrument/CLAUDE.md +++ b/src/core/instrument/CLAUDE.md @@ -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. diff --git a/src/core/instrument/CMakeLists.txt b/src/core/instrument/CMakeLists.txt index 2f6f6ba..e8ccd86 100644 --- a/src/core/instrument/CMakeLists.txt +++ b/src/core/instrument/CMakeLists.txt @@ -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) diff --git a/src/core/instrument/engine/play_params.h b/src/core/instrument/engine/play_params.h index e632623..586c842 100644 --- a/src/core/instrument/engine/play_params.h +++ b/src/core/instrument/engine/play_params.h @@ -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 +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 { diff --git a/src/core/instrument/engine/velocity_curve.cpp b/src/core/instrument/engine/velocity_curve.cpp index 934dcd7..2e11bcb 100644 --- a/src/core/instrument/engine/velocity_curve.cpp +++ b/src/core/instrument/engine/velocity_curve.cpp @@ -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 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 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(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(i), p); - return i; + return static_cast(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; } diff --git a/src/core/instrument/engine/velocity_curve.h b/src/core/instrument/engine/velocity_curve.h index 7cbe3a0..67e2219 100644 --- a/src/core/instrument/engine/velocity_curve.h +++ b/src/core/instrument/engine/velocity_curve.h @@ -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 #include #include 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& 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 diff --git a/src/core/instrument/engine/voice.cpp b/src/core/instrument/engine/voice.cpp index a058035..d95c9e2 100644 --- a/src/core/instrument/engine/voice.cpp +++ b/src/core/instrument/engine/voice.cpp @@ -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(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) diff --git a/src/core/instrument/engine/voice.h b/src/core/instrument/engine/voice.h index f3bc34a..b3d6293 100644 --- a/src/core/instrument/engine/voice.h +++ b/src/core/instrument/engine/voice.h @@ -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(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. diff --git a/src/core/instrument/map/component_state_io.h b/src/core/instrument/map/component_state_io.h index 2e4522c..7dcbb07 100644 --- a/src/core/instrument/map/component_state_io.h +++ b/src/core/instrument/map/component_state_io.h @@ -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 @@ -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 diff --git a/src/core/instrument/map/params_payload.cpp b/src/core/instrument/map/params_payload.cpp index b6501bb..1ce5e27 100644 --- a/src/core/instrument/map/params_payload.cpp +++ b/src/core/instrument/map/params_payload.cpp @@ -50,6 +50,27 @@ void putCurve(std::vector& 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& out, const SplineEnv& s) { + out.push_back(s.mode == EnvMode::Spline ? 1 : 0); + const std::vector& pts = s.contour.points(); + putLE(out, static_cast(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& out, const VelocityCurve& curve) { + const std::vector& pts = curve.points(); + putLE(out, static_cast(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& 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 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(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 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& 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{}; diff --git a/src/core/instrument/map/sample_map.cpp b/src/core/instrument/map/sample_map.cpp index b17c178..f8b91d4 100644 --- a/src/core/instrument/map/sample_map.cpp +++ b/src/core/instrument/map/sample_map.cpp @@ -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; } diff --git a/src/core/instrument/map/sample_map.h b/src/core/instrument/map/sample_map.h index f9e6979..ead32c4 100644 --- a/src/core/instrument/map/sample_map.h +++ b/src/core/instrument/map/sample_map.h @@ -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 diff --git a/src/core/instrument/ui/CMakeLists.txt b/src/core/instrument/ui/CMakeLists.txt index 5cf13ca..21d4881 100644 --- a/src/core/instrument/ui/CMakeLists.txt +++ b/src/core/instrument/ui/CMakeLists.txt @@ -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. diff --git a/src/core/instrument/ui/deck_groups.cpp b/src/core/instrument/ui/deck_groups.cpp index 2320a5b..6ff04ab 100644 --- a/src/core/instrument/ui/deck_groups.cpp +++ b/src/core/instrument/ui/deck_groups.cpp @@ -9,6 +9,11 @@ namespace reasampler::instrument::ui { namespace { int id(DeckParam p) { return static_cast(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 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 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 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(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; } diff --git a/src/core/instrument/ui/deck_groups.h b/src/core/instrument/ui/deck_groups.h index b549f0f..c6aaaf5 100644 --- a/src/core/instrument/ui/deck_groups.h +++ b/src/core/instrument/ui/deck_groups.h @@ -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 diff --git a/src/core/instrument/ui/knob_deck.cpp b/src/core/instrument/ui/knob_deck.cpp index 5594969..97cb051 100644 --- a/src/core/instrument/ui/knob_deck.cpp +++ b/src/core/instrument/ui/knob_deck.cpp @@ -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)) diff --git a/src/core/instrument/ui/knob_deck.h b/src/core/instrument/ui/knob_deck.h index a7644cd..b28c7ce 100644 --- a/src/core/instrument/ui/knob_deck.h +++ b/src/core/instrument/ui/knob_deck.h @@ -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 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 cells; DeckToggleLayout rowToggle; // id -1 when absent }; diff --git a/src/core/instrument/ui/spline_edit.cpp b/src/core/instrument/ui/spline_edit.cpp new file mode 100644 index 0000000..d5c62af --- /dev/null +++ b/src/core/instrument/ui/spline_edit.cpp @@ -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 diff --git a/src/core/instrument/ui/spline_edit.h b/src/core/instrument/ui/spline_edit.h new file mode 100644 index 0000000..31bea11 --- /dev/null +++ b/src/core/instrument/ui/spline_edit.h @@ -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 diff --git a/src/shell/instrument/CLAUDE.md b/src/shell/instrument/CLAUDE.md index 7d66eea..a2b3b55 100644 --- a/src/shell/instrument/CLAUDE.md +++ b/src/shell/instrument/CLAUDE.md @@ -11,7 +11,7 @@ The pure engine/geometry core this shell wraps (`sampler_core`, `pitch_shift`, `sample_map`, `component_state_io`, `play_params.h`, `editor_geometry`, `sample_bands`, `sample_chrome`, `keyboard_strip`, `waveform_view`, `capture_browser`, `browser_scroll`, `param_slider`, `trigger_seam`, `velocity_curve`, `embed_strip`, `knob_deck`, -`deck_groups`, `curve_popup`, `master_gain`, `reasampler_uid.h`) lives in `core/instrument/*` and +`deck_groups`, `curve_popup`, `spline_edit`, `master_gain`, `reasampler_uid.h`) lives in `core/instrument/*` and `core/wire` and is documented there — this directory consumes it but does not own it. ## Invariants @@ -101,7 +101,7 @@ declared ahead of the instrument slots at that member in `reasampler_processor.h - `reaper_bridge` — READ-ONLY bank consumer: receives bank snapshots from the extension and exposes them as a read-only view. **Never writes to the extension's bank** — this is a load-bearing invariant; no mutation path exists in this module. **pS-usage:** gains `writeUsageExtState` (prefix-guarded — accepts only `rsusage_`-prefixed keys, refuses all others) so the processor can publish usage without weakening the read-only-bank invariant. - `reasampler_processor` (`shell/instrument/`: `reasampler_processor.cpp` lifecycle + `process()`, `processor_state.cpp` component-state I/O + UI-thread parameter accessors, `processor_reload.cpp` the off-audio-thread `reloadInstrument`/publish family — Q-W2v, T4-12 split; `process()` and its per-block work stay ONE TU on purpose, no cross-TU call on the per-sample path) — VST3 `SingleComponentEffect` shell: declares event-input bus + **permanently stereo** output (GA fix: dynamic mono↔stereo bus renegotiation deleted; `ChannelMode` is now decode-only), marshals MIDI note-on/off into the VoiceEngine, renders audio; owns off-audio-thread `reloadInstrument` + atomic pointer swap so `process()` does no allocation, no file I/O, no bridge calls. The instance state is `{loaded capture id, one InstrumentParams}`, and `reloadInstrument` resolves + decodes exactly that one capture into the `SampleData` the engine plays. **Self-contained playback (pS):** `ComponentState` v10 adds a `SampleRefs` table — per referenced sample, a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent). The bank/bridge is a browser source: loading a capture copies its reference in; the reopen-heal timer + poll-to-play apparatus are removed. `retireIdleDrain()` retires fully-idle drain snapshots on the UI-timer cadence. Voice-param edits (`setVoiceCount`/`setVoiceMode`/`setMonoTrigger`) rebuild the engine from the already-decoded `SampleData` via the drain-slot swap — no bank re-read, no WAV re-decode, no audible cut to ringing tails. **FB1:** applies the post-mixer `masterGainLinear` (from `ComponentState` v8) as a per-sample ramp over the summed output — no zipper noise. **GA v9:** `channelModeExplicit_` flag persisted; `channelModeFor()` auto-defaults the mode from the loaded capture's channel count when the flag is not set. **pS:** `ComponentState` bumped v9→v10 (`SampleRefs` table); pre-v10 blobs lift to empty refs and re-save self-contained. **pS-usage:** publishes instance usage (held `SampleRefs` paths) to `rsusage_` at the tail of `reloadInstrument` (off audio thread) via `reaper_bridge::writeUsageExtState`; `ComponentState` bumped v10→**v11** (`instanceGuid` field); pre-v11 blobs mint guid on first publish. -- `reasampler_editor` — VST3 `IPlugView` LICE editor shell: hosts a LICE-drawn child window; the Sample face is home and Browse is a modal picker over it. Split on the Sample face's BAND axis, mirroring the pure `sample_bands` allocator: `editor_session` (session/bridge state, caches, commit-and-reload), `editor_controls` (parameter plumbing + the ONE `faceLayout` band resolve every paint and hit-test path shares), then matching paint and input sets — `editor_paint`/`editor_input` (dispatch + drag router + hover dispatch), `_chrome`, `_waveform`, `_deck` — plus the two band-independent surfaces (`_browse` for the modal picker, `_curve` for the velocity-curve popup) and `editor_platform` (IPlugView/Win32 window plumbing). Shared internals in `editor_internal.h`, no TU of its own. Drop-onto-editor ingest is NOT shipped (deferred). +- `reasampler_editor` — VST3 `IPlugView` LICE editor shell: hosts a LICE-drawn child window; the Sample face is home and Browse is a modal picker over it. Split on the Sample face's BAND axis, mirroring the pure `sample_bands` allocator: `editor_session` (session/bridge state, caches, commit-and-reload), `editor_controls` (the control-value domain maps + the node-drag bounds that must match them, plus the ONE `faceLayout` band resolve every paint and hit-test path shares), `editor_models` (the orthogonal half: which stored struct each transient editor selection names — the staged-envelope pack/unpack, the drawn contour, and the three velocity curves), then matching paint and input sets — `editor_paint`/`editor_input` (dispatch + drag router + hover dispatch), `_chrome`, `_waveform`, `_deck` — plus the two band-independent surfaces (`_browse` for the modal picker, `_curve` for the velocity-curve popup) and `editor_platform` (IPlugView/Win32 window plumbing). Shared internals in `editor_internal.h`, no TU of its own. Drop-onto-editor ingest is NOT shipped (deferred). - `reasampler_embed` — implements `IReaperUIEmbedInterface` so the instrument draws inline in the TCP/MCP without a plugin-owned HWND; delegates layout to `embed_strip`. A read-only readout: the loaded capture across the keyboard span with its root marked, plus the activity level. It takes no mouse input (there is nothing on the strip to select). - `vst_entry` — VST3 entry point: `GetPluginFactory` export, class registration, channel-forked class UIDs. - `editor_internal.h` — INTERNAL shared helpers for the `reasampler_editor` TU family, included only by the editor's own shell TUs (`editor_session` / `editor_controls` / `editor_paint_*` / `editor_input_*` / `editor_platform`), never a public seam: the `Rect`↔kit adapters, small draw primitives (knob face / title band), label helpers, and the velocity-curve box derivation — the helpers more than one band TU needs. The deck's control ids, group ids and group composition are the pure `deck_groups` module's, not this file's. The piano-strip and root-key draws live in `editor_paint_chrome`, their only consumer, not here. diff --git a/src/shell/instrument/CMakeLists.txt b/src/shell/instrument/CMakeLists.txt index 56c85a8..6bb35d9 100644 --- a/src/shell/instrument/CMakeLists.txt +++ b/src/shell/instrument/CMakeLists.txt @@ -51,6 +51,7 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp") # matching sets, plus the two band-independent surfaces and the platform TU. editor_session.cpp editor_controls.cpp + editor_models.cpp editor_paint.cpp editor_paint_chrome.cpp editor_paint_waveform.cpp @@ -85,7 +86,7 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp") capture_browser keyboard_strip sample_bands sample_chrome waveform_view bank_sync browser_scroll param_slider tooltip theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit - knob_deck deck_groups curve_popup master_gain sample_usage file_bytes curve_law) + knob_deck deck_groups curve_popup spline_edit master_gain sample_usage file_bytes curve_law) # SDK_INC gives the REAPER VST3 interfaces + API header for the bridge; WDL_INC gives # LICE for the editor. The VST3 SDK headers arrive via vst3_sdk PUBLIC. target_include_directories(reasampler_vst PRIVATE ${REASAMPLER_SRC_DIR} ${SDK_INC} ${WDL_INC}) diff --git a/src/shell/instrument/editor_controls.cpp b/src/shell/instrument/editor_controls.cpp index 51a1779..4f35729 100644 --- a/src/shell/instrument/editor_controls.cpp +++ b/src/shell/instrument/editor_controls.cpp @@ -1,9 +1,9 @@ // editor_controls.cpp — the ReaSamplerEditor's parameter plumbing: the band-stack layout // resolve every paint/hit-test path shares, the control-value domain maps (controlValue / // applyControl — seconds/fraction/frames <-> normalized 0..1), the control-id<->value binding -// against the pure `deck_groups` module's descriptors, and the envelope pack/unpack (which -// stored struct each overlay selection maps onto). Value logic only — no painting, no window -// plumbing. +// against the pure `deck_groups` module's descriptors, and the node-drag clamp bounds that must +// match those domains. The orthogonal half — which stored struct each editor selection names — +// is editor_models. Value logic only: no painting, no window plumbing. #include "shell/instrument/reasampler_editor.h" @@ -99,6 +99,12 @@ double ReaSamplerEditor::controlValue(int id, const PlaySeconds& play) const { const auto secToNorm = [](double s) { return clamp01(s / kEnvTimeMaxSeconds); }; switch (static_cast(id)) { case ParamControl::kPlayMode: return play.playMode == PlayMode::Trigger ? 1.0 : 0.0; + case ParamControl::kAmpEnvMode: + return play.ampSpline.mode == EnvMode::Spline ? 1.0 : 0.0; + case ParamControl::kPitchEnvMode: + return play.pitchSpline.mode == EnvMode::Spline ? 1.0 : 0.0; + case ParamControl::kFilterEnvMode: + return play.filterSpline.mode == EnvMode::Spline ? 1.0 : 0.0; case ParamControl::kPitchEngine: return play.pitchEngine == PitchEngine::Preserve ? 1.0 : 0.0; case ParamControl::kAttack: return secToNorm(play.adsr.attackSeconds); case ParamControl::kHold: return secToNorm(play.adsr.holdSeconds); @@ -164,8 +170,26 @@ void ReaSamplerEditor::applyControl(int id, PlaySeconds& play, double value, const auto normToSec = [](double v) { return clamp01(v) * kEnvTimeMaxSeconds; }; switch (static_cast(id)) { case ParamControl::kPlayMode: + // Gate is refused while any EG is drawn — see splineActive (play_params.h). The + // segment paints Disabled for the same reason, so the refusal is never a surprise. + if (segment == 0 && splineActive(play)) break; play.playMode = (segment == 1) ? PlayMode::Trigger : PlayMode::Gate; break; + // Switching TO Spline drops Gate, which the spline model has no place for. Switching + // back does NOT restore it: the previous mode is not stored, and silently re-gating an + // instrument the user has since heard as a one-shot is the worse surprise. + case ParamControl::kAmpEnvMode: + case ParamControl::kPitchEnvMode: + case ParamControl::kFilterEnvMode: { + const EnvMode m = (segment == 1) ? EnvMode::Spline : EnvMode::Staged; + switch (static_cast(id)) { + case ParamControl::kAmpEnvMode: play.ampSpline.mode = m; break; + case ParamControl::kPitchEnvMode: play.pitchSpline.mode = m; break; + default: play.filterSpline.mode = m; break; + } + if (splineActive(play)) play.playMode = PlayMode::Trigger; + break; + } case ParamControl::kPitchEngine: play.pitchEngine = (segment == 1) ? PitchEngine::Preserve : PitchEngine::Varispeed; break; @@ -409,9 +433,9 @@ std::string ReaSamplerEditor::deckValueLabel(int id) const { } EnvClampBounds ReaSamplerEditor::envClampBounds() const { - // Match the deck knobs' own domains so a node drag can never produce a param a knob - // couldn't. Every stage time caps at kEnvTimeMaxSeconds; the Hold fractions and the sustain - // level are [0,1] by definition and need no bound here. + // Lives here, beside controlValue/applyControl, because it must MATCH them: a node drag can + // never produce a param a knob couldn't. Every stage time caps at kEnvTimeMaxSeconds; the + // Hold fractions and the sustain level are [0,1] by definition and need no bound here. EnvClampBounds b; b.maxAttackSeconds = kEnvTimeMaxSeconds; b.maxHoldSeconds = kEnvTimeMaxSeconds; @@ -420,141 +444,6 @@ EnvClampBounds ReaSamplerEditor::envClampBounds() const { return b; } -namespace { -// The two directions of the AHDSR <-> StageEnvelope copy, so a field can only be forgotten in -// one place rather than two. -void packAhdsr(const AdsrSeconds& a, StageEnvelope& env) { - env.kind = instrument::ui::EnvKind::Ahdsr; - env.attackSeconds = a.attackSeconds; - env.holdSeconds = a.holdSeconds; - env.decaySeconds = a.decaySeconds; - env.sustainLevel = a.sustainLevel; - env.releaseSeconds = a.releaseSeconds; - env.attackCurve = a.attackCurve; - env.decayCurve = a.decayCurve; - env.releaseCurve = a.releaseCurve; -} -void unpackAhdsr(const StageEnvelope& env, AdsrSeconds& a) { - a.attackSeconds = env.attackSeconds; - a.holdSeconds = env.holdSeconds; - a.decaySeconds = env.decaySeconds; - a.sustainLevel = env.sustainLevel; - a.releaseSeconds = env.releaseSeconds; - a.attackCurve = env.attackCurve; - a.decayCurve = env.decayCurve; - a.releaseCurve = env.releaseCurve; -} -void packAhd(const AhdSeconds& a, double originSeconds, double spanSeconds, StageEnvelope& env) { - env.kind = instrument::ui::EnvKind::Ahd; - env.attackSeconds = a.attackSeconds; - env.decaySeconds = a.decaySeconds; - env.holdFraction = a.holdFraction; - env.attackCurve = a.attackCurve; - env.decayCurve = a.decayCurve; - env.originSeconds = originSeconds; - env.spanSeconds = spanSeconds; -} -void unpackAhd(const StageEnvelope& env, AhdSeconds& a) { - a.attackSeconds = env.attackSeconds; - a.decaySeconds = env.decaySeconds; - a.holdFraction = env.holdFraction; - a.attackCurve = env.attackCurve; - a.decayCurve = env.decayCurve; -} -} // namespace - -StageEnvelope ReaSamplerEditor::packEnvelope(OverlayEnv which, const PlaySeconds& play, - std::int64_t frames, - std::int64_t startFrame) const { - StageEnvelope env; - const double rate = liveSampleRate(); - const double t0 = rate > 0.0 ? static_cast(startFrame) / rate : 0.0; - // The Trigger amp and filter AHDs live over the PLAY span; the pitch AHD over the whole - // post-start span, since it keeps running after a Trigger one-shot's amplitude has ended. - const std::int64_t playLen = - triggerPlayLength(play.trigger.lengthFraction, frames, startFrame); - const double playSpan = rate > 0.0 ? static_cast(playLen) / rate : 0.0; - const double fullSpan = - rate > 0.0 ? static_cast((std::max)(std::int64_t{0}, frames - startFrame)) / rate - : 0.0; - const bool trigger = (play.playMode == PlayMode::Trigger); - switch (which) { - case OverlayEnv::kPitch: - packAhd(play.pitchEnv.shape, t0, fullSpan, env); - break; - case OverlayEnv::kFilter: - if (trigger) packAhd(play.filter.trigEnv, t0, playSpan, env); - else packAhdsr(play.filter.env, env); - break; - case OverlayEnv::kAmp: - if (trigger) packAhd(play.trigAhd, t0, playSpan, env); - else packAhdsr(play.adsr, env); - break; - case OverlayEnv::kNone: - break; // nothing is overlay-active; a default-constructed StageEnvelope, not amp - } - return env; -} - -void ReaSamplerEditor::unpackEnvelope(OverlayEnv which, const StageEnvelope& env, - PlaySeconds& play) const { - const bool trigger = (play.playMode == PlayMode::Trigger); - switch (which) { - case OverlayEnv::kPitch: - unpackAhd(env, play.pitchEnv.shape); - break; - case OverlayEnv::kFilter: - if (trigger) unpackAhd(env, play.filter.trigEnv); - else unpackAhdsr(env, play.filter.env); - break; - case OverlayEnv::kAmp: - if (trigger) unpackAhd(env, play.trigAhd); - else unpackAhdsr(env, play.adsr); - break; - case OverlayEnv::kNone: - break; // nothing is overlay-active, so there is nothing a drag could have edited - } -} - -const VelocityCurve& ReaSamplerEditor::curveFor(CurveTarget target) const { - switch (target) { - case CurveTarget::kPitch: return params_.play.pitchVelocityCurve; - case CurveTarget::kFilter: return params_.play.filter.velocityCurve; - case CurveTarget::kAmp: - case CurveTarget::kNone: - // kNone reads as amp — a valid, harmless choice for a target-agnostic caller (a - // disabled deck cell still needs SOME curve to draw). editedCurve()'s mutable - // overload below refuses kNone rather than relying on this fallback to protect amp. - return params_.velocityCurve; - } - return params_.velocityCurve; -} - -VelocityCurve& ReaSamplerEditor::curveFor(CurveTarget target) { - return const_cast(std::as_const(*this).curveFor(target)); -} - -const VelocityCurve& ReaSamplerEditor::editedCurve() const { return curveFor(curvePopup_); } - -VelocityCurve& ReaSamplerEditor::editedCurve() { - // Refuses kNone rather than aliasing amp (see curvePopup_'s declaration for why); should be - // unreachable now that every writer of kNone also cancels the drag, but this is the second, - // independent line of defense. - if (curvePopup_ == CurveTarget::kNone) { - static VelocityCurve sink = VelocityCurve::flat(); - return sink; - } - return curveFor(curvePopup_); -} - -void ReaSamplerEditor::closeCurvePopup() { - curvePopup_ = CurveTarget::kNone; - if (drag_ == DragKind::kCurveNode) { - drag_ = DragKind::kNone; - curvePointIndex_ = -1; - } -} - void ReaSamplerEditor::applyParamControl(int id, double value, int segment) { if (id == static_cast(ParamControl::kKeyTrack)) { // keyTrack sits beside the play bundle (0..200% over kKeyTrackMax); the knob maps 0..1. diff --git a/src/shell/instrument/editor_input_curve.cpp b/src/shell/instrument/editor_input_curve.cpp index 8990c5e..6f51743 100644 --- a/src/shell/instrument/editor_input_curve.cpp +++ b/src/shell/instrument/editor_input_curve.cpp @@ -1,6 +1,7 @@ // editor_input_curve.cpp — the velocity-curve popup's input: the modal click routing, -// node grab/add/Alt-delete inside the curve box, the live node drag, the right-click -// delete, and the popup's hover. Band-independent (the sheet floats over the whole face). +// node grab/add/toggle/delete inside the curve box, the live node drag, the right-click +// delete on EITHER spline surface, and the popup's hover. Band-independent (the sheet floats +// over the whole face). // Windows-only. #include "shell/instrument/reasampler_editor.h" @@ -8,6 +9,8 @@ #ifdef _WIN32 #include "core/instrument/ui/curve_popup.h" // computeCurvePopup / popupOutsideSheet +#include "core/instrument/ui/spline_edit.h" // the shared point-editing grammar +#include "core/instrument/ui/waveform_view.h" // waveformOverlayArea (the overlay right-click) #include "shell/instrument/editor_internal.h" // curveBoxFromRect #include "shell/instrument/reasampler_processor.h" @@ -42,37 +45,37 @@ void ReaSamplerEditor::handleCurveMouseDown(const Rect& r, int x, int y) { const VelocityCurve::Box box = curveBoxFromRect(r); if (box.width <= 0 || box.height <= 1) return; - int idx = editedCurve().pointAtPixel(box, x, y); - - // Modifier-click (Alt) deletes an interior node — a discrete, final edit committed at once - // (deletePoint refuses the two endpoints, so an Alt-click on them is a safe no-op). - if (idx >= 0 && (GetKeyState(VK_MENU) & 0x8000) != 0) { - if (editedCurve().deletePoint(static_cast(idx))) { + // Alt-click delete predates the converged grammar and is kept as a landed alternate; every + // other gesture routes through the shared resolver so both spline consumers stay one + // grammar. + if ((GetKeyState(VK_MENU) & 0x8000) != 0) { + const int alt = editedCurve().pointAtPixel(box, x, y); + if (alt >= 0 && editedCurve().deletePoint(static_cast(alt))) { hover_ = HoverTarget{}; // a stale kCurveNode index would light a shifted node commitAndReload(); } return; } + const bool ctrl = (GetKeyState(VK_CONTROL) & 0x8000) != 0; + const SplineEdit edit = resolveSplineEdit( + editedCurve(), box, ctrl ? SplineGesture::kControlLeft : SplineGesture::kLeft, x, y); + if (edit.kind == SplineEditKind::kToggleHard) { + if (editedCurve().toggleHard(static_cast(edit.index))) commitAndReload(); + return; + } + if (edit.kind == SplineEditKind::kNone) return; // ring click with no node hit // Snapshot BEFORE any mutation so a capture-loss rollback also cancels an in-flight ADD // (mirror of the other parameter-editing drags' dragStartParams_ contract). dragStartParams_ = params_; - // Empty-space click inside the mapping box: add a control point via the pure inverse map, - // then grab it. Box-gated (not just contains(r,x,y)) because the inset ring must not add a - // point — it would clamp to velocity 0/127, stacking an undeletable duplicate on an - // endpoint. A ring click can still grab an existing node (handled above). - if (idx < 0) { - const bool inBox = (x >= box.left && x < box.left + box.width && - y >= box.top && y < box.top + box.height); - if (inBox) { - const VelocityPoint p = editedCurve().pointFromPixel(box, x, y); - idx = static_cast(editedCurve().addPoint(p.velocity, p.value)); - } + int idx = edit.index; + if (edit.kind == SplineEditKind::kAdd) { + const VelocityPoint p = editedCurve().pointFromPixel(box, x, y); + idx = editedCurve().addPoint(p.velocity, p.value); + if (idx < 0) return; // at the point ceiling: refused, curve untouched } - if (idx < 0) return; // ring click with no node hit — nothing to grab - drag_ = DragKind::kCurveNode; curvePointIndex_ = idx; dragStartCurve_ = editedCurve(); // AFTER the add — resolvePointDrag's delta base @@ -94,23 +97,30 @@ void ReaSamplerEditor::dragCurve(int x, int y) { } void ReaSamplerEditor::onMouseRDown(int x, int y) { - // Right-click on a popup curve node deletes it — the primary delete affordance; Alt-click - // and drag-off remain as landed alternates. Commits immediately through the same path as - // Alt-click; deletePoint's endpoint guard makes an endpoint right-click a safe no-op. - // Right-clicks act only while the popup is open, and never during an in-flight left drag. - if (!processor_ || view_ == View::kBrowse || curvePopup_ == CurveTarget::kNone) return; + // Right-click deletes a node on EITHER spline surface — the primary delete affordance; + // Alt-click and drag-off remain as landed alternates in the popup. deletePoint's endpoint + // guard makes an endpoint right-click a safe no-op. Never acts during an in-flight left + // drag; the modal popup wins when it is open. + if (!processor_ || view_ == View::kBrowse) return; if (drag_ != DragKind::kNone) return; RECT rc{}; GetClientRect(childHwnd_, &rc); - const CurvePopupLayout pl = computeCurvePopup(rc.right - rc.left, rc.bottom - rc.top); - if (!contains(pl.curveBox, x, y)) return; - const VelocityCurve::Box box = curveBoxFromRect(pl.curveBox); - const int idx = editedCurve().pointAtPixel(box, x, y); - if (idx < 0) return; - if (editedCurve().deletePoint(static_cast(idx))) { - hover_ = HoverTarget{}; // a stale kCurveNode index would light a shifted node - commitAndReload(); + if (curvePopup_ != CurveTarget::kNone) { + const CurvePopupLayout pl = computeCurvePopup(rc.right - rc.left, rc.bottom - rc.top); + if (!contains(pl.curveBox, x, y)) return; + const SplineEdit edit = resolveSplineEdit(editedCurve(), curveBoxFromRect(pl.curveBox), + SplineGesture::kRight, x, y); + if (edit.kind != SplineEditKind::kDelete) return; + if (editedCurve().deletePoint(static_cast(edit.index))) { + hover_ = HoverTarget{}; // a stale kCurveNode index would light a shifted node + commitAndReload(); + } + return; } + if (!overlayIsSpline() || !overlayEnvEnabled(overlayEnv_, deckEnableState())) return; + const FaceLayout fl = faceLayout(rc.right - rc.left, rc.bottom - rc.top); + splineOverlayClick(waveformOverlayArea(fl.bands.waveform), x, y, SplineGesture::kRight, + /*addOnEmptySpace=*/false); } ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverCurvePopup(int w, int h, int x, diff --git a/src/shell/instrument/editor_input_deck.cpp b/src/shell/instrument/editor_input_deck.cpp index 41a36e0..d398b8b 100644 --- a/src/shell/instrument/editor_input_deck.cpp +++ b/src/shell/instrument/editor_input_deck.cpp @@ -19,8 +19,7 @@ using namespace reasampler::instrument::ui; bool ReaSamplerEditor::deckKnobDisabled(int id) const { // Thin int-id wrapper over the pure, CTest-covered predicate — see deckKnobInert // (deck_groups.h) for which cells go inert and why. - return deckKnobInert(static_cast(id), params_.play.pitchEnv.enabled, - params_.play.filter.enabled); + return deckKnobInert(static_cast(id), deckEnableState()); } bool ReaSamplerEditor::mouseDownDeck(const FaceLayout& fl, int x, int y) { diff --git a/src/shell/instrument/editor_input_waveform.cpp b/src/shell/instrument/editor_input_waveform.cpp index 8ce057a..cd48ded 100644 --- a/src/shell/instrument/editor_input_waveform.cpp +++ b/src/shell/instrument/editor_input_waveform.cpp @@ -14,6 +14,7 @@ #include "core/instrument/engine/loop/loop_span.h" // maxCrossfade (the shared drag-clamp bound) #include "core/instrument/ui/envelope_edit.h" // nodeAtPoint / resolveNodeDrag +#include "core/instrument/ui/spline_edit.h" // the shared point-editing grammar #include "core/instrument/ui/waveform_view.h" // waveformOverlayArea / markerAtPoint / snap #include "shell/instrument/editor_internal.h" #include "shell/instrument/reasampler_processor.h" @@ -30,13 +31,24 @@ bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) { if (frames <= 0) return false; const OverlayArea overlay = waveformOverlayArea(fl.bands.waveform); + // A drawn EG's own point actions come first, for the same reason the staged nodes do — + // they sit on top of the markers. Its ADD, which any empty-space click satisfies, is held + // back until after the markers below, or a spline overlay would make them unreachable. + const DeckEnableState gates = deckEnableState(); + const bool splineLive = overlayIsSpline() && overlayEnvEnabled(overlayEnv_, gates); + const SplineGesture gesture = (GetKeyState(VK_CONTROL) & 0x8000) != 0 + ? SplineGesture::kControlLeft + : SplineGesture::kLeft; + if (splineLive && splineOverlayClick(overlay, x, y, gesture, /*addOnEmptySpace=*/false)) { + return true; + } + // Envelope nodes first (they sit on top of the markers), then the wave markers. With no // envelope overlay-active — or with its deck group's enable toggle off, which makes the // same params' knobs inert — there are no grabbable nodes and the markers take every grab. const double rate = liveSampleRate(); const bool nodesLive = - overlayEnv_ != OverlayEnv::kNone && - !overlayEnvInert(overlayEnv_, params_.play.pitchEnv.enabled, params_.play.filter.enabled); + overlayEnv_ != OverlayEnv::kNone && !overlayEnvInert(overlayEnv_, gates); if (rate > 0.0 && nodesLive) { const std::int64_t startFrame = params_.startPoint.value_or(0); const StageEnvelope env = packEnvelope(overlayEnv_, params_.play, frames, startFrame); @@ -71,9 +83,54 @@ bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) { beginMarkerDrag(static_cast(hit), m, frames, x); return true; } + // Nothing else wanted the click: now the drawn contour may take the empty space. + if (splineLive) return splineOverlayClick(overlay, x, y, gesture, /*addOnEmptySpace=*/true); return false; } +bool ReaSamplerEditor::splineOverlayClick(const OverlayArea& waveArea, int x, int y, + SplineGesture gesture, bool addOnEmptySpace) { + const VelocityCurve::Box box = splineOverlayBox(waveArea); + VelocityCurve& contour = splineFor(overlayEnv_); + const SplineEdit edit = resolveSplineEdit(contour, box, gesture, x, y); + if (edit.kind == SplineEditKind::kAdd && !addOnEmptySpace) return false; + switch (edit.kind) { + case SplineEditKind::kNone: + return false; + case SplineEditKind::kDelete: + // deletePoint refuses the two endpoints, so a right-click on one is a safe no-op. + if (!contour.deletePoint(static_cast(edit.index))) return true; + hover_ = HoverTarget{}; // a stale index would light a shifted node + commitAndReload(); + return true; + case SplineEditKind::kToggleHard: + if (!contour.toggleHard(static_cast(edit.index))) return true; + commitAndReload(); + return true; + case SplineEditKind::kAdd: + case SplineEditKind::kGrab: + break; + } + // Snapshot BEFORE the add so a capture-loss rollback cancels the in-flight point too + // (the same contract the other parameter-editing drags keep). + dragStartParams_ = params_; + int index = edit.index; + if (edit.kind == SplineEditKind::kAdd) { + const VelocityPoint p = contour.pointFromPixel(box, x, y); + index = contour.addPoint(p.velocity, p.value); + // At the ceiling: refused, contour untouched, nothing to roll back. Swallow the click + // rather than letting it fall through to a marker grab under the cursor. + if (index < 0) return true; + } + drag_ = DragKind::kSplineNode; + curvePointIndex_ = index; + dragStartCurve_ = contour; // AFTER the add — resolvePointDrag's delta base + dragStartX_ = x; + dragStartY_ = y; + invalidate(); // live feedback; the commit lands on WM_LBUTTONUP + return true; +} + void ReaSamplerEditor::beginMarkerDrag(WaveMarker which, const SetupMarkers& m, std::int64_t frames, int x) { drag_ = DragKind::kWaveMarker; @@ -88,6 +145,17 @@ void ReaSamplerEditor::dragWaveform(const FaceLayout& fl, int x, int y) { const OverlayArea overlay = waveformOverlayArea(fl.bands.waveform); const int dx = x - dragStartX_; + if (drag_ == DragKind::kSplineNode) { + // Same absolute-delta contract as the popup's node drag, against the grab-time contour + // and the overlay's own box. + if (curvePointIndex_ < 0) return; + splineFor(overlayEnv_) = VelocityCurve::resolvePointDrag( + dragStartCurve_, static_cast(curvePointIndex_), + splineOverlayBox(overlay), dx, y - dragStartY_); + invalidate(); // live feedback; commit on release + return; + } + if (drag_ == DragKind::kEnvNode) { // Resolve the grabbed envelope node's new params from the pixel delta (through the // pure envelope_edit inverse map, clamped), then unpack them back onto the parameter diff --git a/src/shell/instrument/editor_models.cpp b/src/shell/instrument/editor_models.cpp new file mode 100644 index 0000000..8128f8e --- /dev/null +++ b/src/shell/instrument/editor_models.cpp @@ -0,0 +1,188 @@ +// editor_models.cpp — the ReaSamplerEditor's model-selection half: which STORED struct each +// transient editor selection names. The overlay selection maps onto a staged envelope +// (pack/unpack) or a drawn contour; a curve-popup target maps onto one of the three velocity +// curves. One switch per family, so a moved field or a fourth curve is a one-place edit. +// Split from editor_controls, which owns the orthogonal half: the control-value domain maps. + +#include "shell/instrument/reasampler_editor.h" + +#include +#include // std::as_const (the const/non-const accessor pairs) + +#include "core/instrument/map/trigger_seam.h" // triggerPlayLength (the Trigger play span) +#include "shell/instrument/reasampler_processor.h" + +namespace reasampler::vst { + +using namespace reasampler::instrument::map; // PlaySeconds vocabulary + trigger_seam + +namespace { +// The two directions of the AHDSR <-> StageEnvelope copy, so a field can only be forgotten in +// one place rather than two. +void packAhdsr(const AdsrSeconds& a, StageEnvelope& env) { + env.kind = instrument::ui::EnvKind::Ahdsr; + env.attackSeconds = a.attackSeconds; + env.holdSeconds = a.holdSeconds; + env.decaySeconds = a.decaySeconds; + env.sustainLevel = a.sustainLevel; + env.releaseSeconds = a.releaseSeconds; + env.attackCurve = a.attackCurve; + env.decayCurve = a.decayCurve; + env.releaseCurve = a.releaseCurve; +} +void unpackAhdsr(const StageEnvelope& env, AdsrSeconds& a) { + a.attackSeconds = env.attackSeconds; + a.holdSeconds = env.holdSeconds; + a.decaySeconds = env.decaySeconds; + a.sustainLevel = env.sustainLevel; + a.releaseSeconds = env.releaseSeconds; + a.attackCurve = env.attackCurve; + a.decayCurve = env.decayCurve; + a.releaseCurve = env.releaseCurve; +} +void packAhd(const AhdSeconds& a, double originSeconds, double spanSeconds, StageEnvelope& env) { + env.kind = instrument::ui::EnvKind::Ahd; + env.attackSeconds = a.attackSeconds; + env.decaySeconds = a.decaySeconds; + env.holdFraction = a.holdFraction; + env.attackCurve = a.attackCurve; + env.decayCurve = a.decayCurve; + env.originSeconds = originSeconds; + env.spanSeconds = spanSeconds; +} +void unpackAhd(const StageEnvelope& env, AhdSeconds& a) { + a.attackSeconds = env.attackSeconds; + a.decaySeconds = env.decaySeconds; + a.holdFraction = env.holdFraction; + a.attackCurve = env.attackCurve; + a.decayCurve = env.decayCurve; +} +} // namespace + +StageEnvelope ReaSamplerEditor::packEnvelope(OverlayEnv which, const PlaySeconds& play, + std::int64_t frames, + std::int64_t startFrame) const { + StageEnvelope env; + const double rate = liveSampleRate(); + const double t0 = rate > 0.0 ? static_cast(startFrame) / rate : 0.0; + // The Trigger amp and filter AHDs live over the PLAY span; the pitch AHD over the whole + // post-start span, since it keeps running after a Trigger one-shot's amplitude has ended. + const std::int64_t playLen = + triggerPlayLength(play.trigger.lengthFraction, frames, startFrame); + const double playSpan = rate > 0.0 ? static_cast(playLen) / rate : 0.0; + const double fullSpan = + rate > 0.0 ? static_cast((std::max)(std::int64_t{0}, frames - startFrame)) / rate + : 0.0; + const bool trigger = (play.playMode == PlayMode::Trigger); + switch (which) { + case OverlayEnv::kPitch: + packAhd(play.pitchEnv.shape, t0, fullSpan, env); + break; + case OverlayEnv::kFilter: + if (trigger) packAhd(play.filter.trigEnv, t0, playSpan, env); + else packAhdsr(play.filter.env, env); + break; + case OverlayEnv::kAmp: + if (trigger) packAhd(play.trigAhd, t0, playSpan, env); + else packAhdsr(play.adsr, env); + break; + case OverlayEnv::kNone: + break; // nothing is overlay-active; a default-constructed StageEnvelope, not amp + } + return env; +} + +void ReaSamplerEditor::unpackEnvelope(OverlayEnv which, const StageEnvelope& env, + PlaySeconds& play) const { + const bool trigger = (play.playMode == PlayMode::Trigger); + switch (which) { + case OverlayEnv::kPitch: + unpackAhd(env, play.pitchEnv.shape); + break; + case OverlayEnv::kFilter: + if (trigger) unpackAhd(env, play.filter.trigEnv); + else unpackAhdsr(env, play.filter.env); + break; + case OverlayEnv::kAmp: + if (trigger) unpackAhd(env, play.trigAhd); + else unpackAhdsr(env, play.adsr); + break; + case OverlayEnv::kNone: + break; // nothing is overlay-active, so there is nothing a drag could have edited + } +} + +instrument::ui::DeckEnableState ReaSamplerEditor::deckEnableState() const { + const PlaySeconds& play = params_.play; + return instrument::ui::DeckEnableState{ + play.pitchEnv.enabled, play.filter.enabled, + play.ampSpline.mode == EnvMode::Spline, + play.pitchSpline.mode == EnvMode::Spline, + play.filterSpline.mode == EnvMode::Spline}; +} + +const VelocityCurve& ReaSamplerEditor::splineFor(OverlayEnv which) const { + switch (which) { + case OverlayEnv::kPitch: return params_.play.pitchSpline.contour; + case OverlayEnv::kFilter: return params_.play.filterSpline.contour; + case OverlayEnv::kAmp: + case OverlayEnv::kNone: + break; + } + return params_.play.ampSpline.contour; +} + +VelocityCurve& ReaSamplerEditor::splineFor(OverlayEnv which) { + return const_cast(std::as_const(*this).splineFor(which)); +} + +bool ReaSamplerEditor::overlayIsSpline() const { + switch (overlayEnv_) { + case OverlayEnv::kAmp: return params_.play.ampSpline.mode == EnvMode::Spline; + case OverlayEnv::kPitch: return params_.play.pitchSpline.mode == EnvMode::Spline; + case OverlayEnv::kFilter: return params_.play.filterSpline.mode == EnvMode::Spline; + case OverlayEnv::kNone: return false; + } + return false; +} + +const VelocityCurve& ReaSamplerEditor::curveFor(CurveTarget target) const { + switch (target) { + case CurveTarget::kPitch: return params_.play.pitchVelocityCurve; + case CurveTarget::kFilter: return params_.play.filter.velocityCurve; + case CurveTarget::kAmp: + case CurveTarget::kNone: + // kNone reads as amp — a valid, harmless choice for a target-agnostic caller (a + // disabled deck cell still needs SOME curve to draw). editedCurve()'s mutable + // overload below refuses kNone rather than relying on this fallback to protect amp. + return params_.velocityCurve; + } + return params_.velocityCurve; +} + +VelocityCurve& ReaSamplerEditor::curveFor(CurveTarget target) { + return const_cast(std::as_const(*this).curveFor(target)); +} + +const VelocityCurve& ReaSamplerEditor::editedCurve() const { return curveFor(curvePopup_); } + +VelocityCurve& ReaSamplerEditor::editedCurve() { + // Refuses kNone rather than aliasing amp (see curvePopup_'s declaration for why); should be + // unreachable now that every writer of kNone also cancels the drag, but this is the second, + // independent line of defense. + if (curvePopup_ == CurveTarget::kNone) { + static VelocityCurve sink = VelocityCurve::flat(); + return sink; + } + return curveFor(curvePopup_); +} + +void ReaSamplerEditor::closeCurvePopup() { + curvePopup_ = CurveTarget::kNone; + if (drag_ == DragKind::kCurveNode) { + drag_ = DragKind::kNone; + curvePointIndex_ = -1; + } +} + +} // namespace reasampler::vst diff --git a/src/shell/instrument/editor_paint_deck.cpp b/src/shell/instrument/editor_paint_deck.cpp index 661a568..fd73177 100644 --- a/src/shell/instrument/editor_paint_deck.cpp +++ b/src/shell/instrument/editor_paint_deck.cpp @@ -30,13 +30,16 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) { // One compact-toggle draw (the Mono/Stereo segment grammar at Micro scale). Disabled // segments draw inert so the dependency (Retrig|Legato needs Mono) reads at a glance. + // `seg0Disabled` disables ONE segment: Gate is unselectable while an EG is drawn, but + // Trigger — the mode it is stuck in — must still read as the live choice. const auto drawToggle = [&](const DeckToggleLayout& t, const char* s0, const char* s1, - bool seg1Active, bool disabled) { + bool seg1Active, bool disabled, bool seg0Disabled = false) { const bool hov = !disabled && isHovered(HoverKind::kControl, t.id); + const bool d0 = disabled || seg0Disabled; const InteractionState st0 = - disabled ? InteractionState::Disabled - : (!seg1Active ? InteractionState::Active - : (hov ? InteractionState::Hover : InteractionState::Rest)); + d0 ? InteractionState::Disabled + : (!seg1Active ? InteractionState::Active + : (hov ? InteractionState::Hover : InteractionState::Rest)); const InteractionState st1 = disabled ? InteractionState::Disabled : (seg1Active ? InteractionState::Active @@ -44,12 +47,13 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) { fillSurface(bmp, toKitBox(t.seg0), Role::BgCell, st0); fillSurface(bmp, toKitBox(t.seg1), Role::BgCell, st1); kitTextCentered(bmp, t.seg0, s0, Font::Micro, - disabled ? Role::TextDim - : (!seg1Active ? Role::BgBase : Role::TextPrimary)); + d0 ? Role::TextDim + : (!seg1Active ? Role::BgBase : Role::TextPrimary)); kitTextCentered(bmp, t.seg1, s1, Font::Micro, disabled ? Role::TextDim : (seg1Active ? Role::BgBase : Role::TextPrimary)); }; + const bool anySpline = splineActive(play); // The knob's short name label (swapped for the live value during hover/drag — no third // line, no permanent value clutter). @@ -132,25 +136,32 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) { } } - // The compact caption toggle (right-anchored in the caption row, never full-width). - if (g.captionToggle.id >= 0) { - switch (static_cast(g.captionToggle.id)) { + // The compact caption toggles (right-anchored in the caption row, never full-width). + for (const DeckToggleLayout* tp : {&g.captionToggle, &g.captionToggle2}) { + if (tp->id < 0) continue; + const DeckToggleLayout& t = *tp; + switch (static_cast(t.id)) { case ParamControl::kPlayMode: - drawToggle(g.captionToggle, "Gate", "Trigger", - play.playMode == PlayMode::Trigger, false); + drawToggle(t, "Gate", "Trigger", play.playMode == PlayMode::Trigger, false, + /*seg0Disabled=*/anySpline); break; case ParamControl::kPitchEngine: - drawToggle(g.captionToggle, "Varisp", "Presrv", + drawToggle(t, "Varisp", "Presrv", play.pitchEngine == PitchEngine::Preserve, false); break; case ParamControl::kPitchEnvEnable: - drawToggle(g.captionToggle, "Off", "On", play.pitchEnv.enabled, false); + drawToggle(t, "Off", "On", play.pitchEnv.enabled, false); break; case ParamControl::kVoiceMode: - drawToggle(g.captionToggle, "Poly", "Mono", isMono, false); + drawToggle(t, "Poly", "Mono", isMono, false); break; case ParamControl::kFilterEnable: - drawToggle(g.captionToggle, "Off", "On", play.filter.enabled, false); + drawToggle(t, "Off", "On", play.filter.enabled, false); + break; + case ParamControl::kAmpEnvMode: + case ParamControl::kPitchEnvMode: + case ParamControl::kFilterEnvMode: + drawToggle(t, "Stg", "Spl", deckControlNorm(t.id) > 0.5, false); break; default: break; } diff --git a/src/shell/instrument/editor_paint_waveform.cpp b/src/shell/instrument/editor_paint_waveform.cpp index 82ebbf7..37c0c0b 100644 --- a/src/shell/instrument/editor_paint_waveform.cpp +++ b/src/shell/instrument/editor_paint_waveform.cpp @@ -13,6 +13,7 @@ #include #include "core/audio/peaks.h" // computeEnvelope (waveform binning) +#include "core/instrument/ui/spline_edit.h" // splineOverlayBox (the contour's mapping box) #include "core/instrument/ui/waveform_view.h" // waveformSurface / laneEnvelope / frameToX #include "shell/instrument/editor_internal.h" // kit adapters #include "shell/instrument/reasampler_processor.h" @@ -131,11 +132,59 @@ void ReaSamplerEditor::paintWaveform(LICE_IBitmap* bmp, const Rect& band) { paintEnvelopeOverlay(bmp, overlay, frames); } +void ReaSamplerEditor::paintSplineOverlay(LICE_IBitmap* bmp, const OverlayArea& waveArea) { + const VelocityCurve::Box box = splineOverlayBox(waveArea); + if (box.width <= 0 || box.height <= 1) return; + const VelocityCurve& curve = splineFor(overlayEnv_); + + // One eval per drawn column, through the curve's own pixel maps, so the trace and the + // handles share the coordinate system the hit-test resolves against. + const LICE_pixel line = toLice(roleColor(Role::OverlayTrace)); + int prevX = 0, prevY = 0; + for (int px = 0; px <= box.width; ++px) { + const int cx = box.left + px; + const double t = curve.pointFromPixel(box, cx, box.top).velocity; + const int cy = curve.pixelFromPoint(box, {t, curve.eval(t)}).y; + if (px > 0) LICE_Line(bmp, prevX, prevY, cx, cy, line, 1.0f, 0, true); + prevX = cx; + prevY = cy; + } + + // Handles carry two independent states on the same mark, so they use two independent + // channels: SIZE is the grab (the staged painter's grammar — a hotter hue reads as lower + // contrast over the lime, see its note), and HOLLOW is hard. The corner itself is the + // primary hard cue, but a corner between two near-collinear segments has none to show. + const LICE_pixel handle = toLice(roleColor(Role::OverlayTrace)); + const LICE_pixel core = toLice(roleColor(Role::BgBase)); + const std::vector& pts = curve.points(); + for (std::size_t i = 0; i < pts.size(); ++i) { + const auto np = curve.pixelFromPoint(box, pts[i]); + const bool grabbed = + (drag_ == DragKind::kSplineNode && curvePointIndex_ == static_cast(i)); + const int r = grabbed ? kEnvHandleGrabbedRadius : kEnvHandleRadius; + const int ir = r - kEnvHandleRingPx; + const int hx = (std::max)(box.left + r, + (std::min)(box.left + box.width - 1 - r, np.x)); + const int hy = (std::max)(box.top + r, + (std::min)(box.top + box.height - 1 - r, np.y)); + LICE_FillRect(bmp, hx - r, hy - r, 2 * r, 2 * r, handle, 1.0f, 0); + if (pts[i].hard && ir > 0) { + LICE_FillRect(bmp, hx - ir, hy - ir, 2 * ir, 2 * ir, core, 1.0f, 0); + } + } +} + void ReaSamplerEditor::paintEnvelopeOverlay(LICE_IBitmap* bmp, const OverlayArea& waveArea, std::int64_t frames) { if (overlayEnv_ == OverlayEnv::kNone) return; // no envelope selected is a resting state const Rect& area = waveArea.rect; if (frames <= 0 || area.width <= 0 || area.height <= 0) return; + if (overlayIsSpline()) { + // The contour is a pure function of normalized position, so it needs neither the frame + // count nor the live rate the staged path below resolves its seconds against. + paintSplineOverlay(bmp, waveArea); + return; + } const double rate = liveSampleRate(); if (rate <= 0.0) return; const double totalSeconds = static_cast(frames) / rate; diff --git a/src/shell/instrument/reasampler_editor.h b/src/shell/instrument/reasampler_editor.h index 07a53ba..c0da07b 100644 --- a/src/shell/instrument/reasampler_editor.h +++ b/src/shell/instrument/reasampler_editor.h @@ -20,6 +20,7 @@ #include "core/instrument/ui/envelope_overlay.h" // StageEnvelope / EnvNode (envelope overlay draw seam) #include "core/instrument/ui/knob_deck.h" // DeckGroupDesc / DeckLayout (the deck band) #include "core/instrument/ui/sample_bands.h" // SampleBands (the band-stack allocator) +#include "core/instrument/ui/spline_edit.h" // the shared point-editing grammar #include "core/instrument/ui/sample_chrome.h" // ChromeRects (chrome-band interior) #include "core/audio/peaks.h" // Envelope (the cached peak thumbnail) #include "core/instrument/map/sample_map.h" // SampleChoice, BankChoice, InstrumentParams @@ -78,8 +79,11 @@ private: // What a mouse drag is currently editing. kWaveMarker/kEnvNode/kCurveNode track their // grabbed item in waveMarker_/envNode_/curvePointIndex_; kDeckKnob is a grab-anchored // knob drag (control in dragParamId_, grab value in dragKnobStartValue_). + // kSplineNode is the overlay's peer of kCurveNode: the same VelocityCurve point drag, over + // the waveform overlay's box and the overlay-active envelope's contour rather than the + // popup's box and curve. enum class DragKind { kNone, kRootMarker, kWaveMarker, kScrollThumb, kEnvNode, - kCurveNode, kDeckKnob }; + kCurveNode, kSplineNode, kDeckKnob }; // Which envelope the waveform overlay is drawing and editing. The selection type and its // whole state machine are the pure deck_groups module's; this alias keeps the shell's @@ -168,9 +172,20 @@ private: // The velocity transfer-curve editor (X = velocity 0-127, Y = the curve's own domain); its // only host is the popup sheet. `r` empty -> draws nothing. void paintVelocityCurve(LICE_IBitmap* bmp, const Rect& r); - // Traces the amp-envelope overlay + its draggable node handles over `waveArea`, ONCE at - // full band height (never per lane). + // Traces the overlay-active envelope + its draggable handles over `waveArea`, ONCE at + // full band height (never per lane). Dispatches on the envelope's mode: the staged + // polyline, or the drawn contour below. void paintEnvelopeOverlay(LICE_IBitmap* bmp, const OverlayArea& waveArea, std::int64_t frames); + // The spline EG's contour + point handles, spanning the overlay 1:1 with the sample's time + // axis. Hard points draw hollow so a corner is legible before it is steep. + void paintSplineOverlay(LICE_IBitmap* bmp, const OverlayArea& waveArea); + + // A click on the spline overlay, routed through the shared grammar; true when it consumed + // the click. `addOnEmptySpace` false resolves node actions only — the waveform band calls + // it that way BEFORE the start/loop markers and again after, since the contour's box is the + // whole band and an unconditional add would make every marker unreachable. + bool splineOverlayClick(const OverlayArea& waveArea, int x, int y, + instrument::ui::SplineGesture gesture, bool addOnEmptySpace); // --- Input: the mouse-down dispatch and its per-band branches --- void onMouseDown(int x, int y); @@ -194,13 +209,15 @@ private: void onMouseMove(int x, int y); void onMouseUp(int x, int y); - // Right-click is the curve popup's primary node-delete affordance; only acts while the - // popup is open (deletePoint's endpoint guard makes an endpoint right-click a no-op). + // Right-click is the primary node-delete affordance on both spline surfaces — the popup + // while it is open, else the spline EG overlay (deletePoint's endpoint guard makes an + // endpoint right-click a no-op). void onMouseRDown(int x, int y); - // Mouse-down inside curve-editor box `r`: a node grab starts a kCurveNode drag; - // Alt-click on an interior node deletes it at once; an empty-space click adds a point - // and grabs it. + // Mouse-down inside curve-editor box `r`, resolved through the shared point-editing + // grammar (spline_edit): a node grab starts a kCurveNode drag, an empty-space click adds a + // point and grabs it, control-click toggles a node hard/smooth. Alt-click delete predates + // that grammar and is kept as a landed alternate. void handleCurveMouseDown(const Rect& r, int x, int y); // Left-click while the curve popup is open (modal over the Sample face): Close / @@ -466,6 +483,20 @@ private: CurveTarget curvePopup_ = CurveTarget::kNone; void closeCurvePopup(); + // The group-gate state the two pure inert predicates read, built once from the parameter + // set so paint and hit-test can never assemble it differently. + instrument::ui::DeckEnableState deckEnableState() const; + + // THE one switch from an overlay selection to the drawn contour it names, mirroring + // curveFor. kNone reads as amp — harmless for a target-agnostic caller, and every mutating + // path is gated on overlayIsSpline() first. + const VelocityCurve& splineFor(OverlayEnv which) const; + VelocityCurve& splineFor(OverlayEnv which); + + // Whether the overlay-active envelope is in Spline mode — the branch every overlay paint, + // hit-test and drag path takes before touching either model. + bool overlayIsSpline() const; + // THE one switch from a CurveTarget to the parameter-set curve it names — paint (button // thumbnails, for every target) and edit (editedCurve, for curvePopup_ specifically) both // route through it, so a fourth curve or a moved field is a one-place edit. diff --git a/tests/test_component_state_io.cpp b/tests/test_component_state_io.cpp index 43cf04f..b468e5a 100644 --- a/tests/test_component_state_io.cpp +++ b/tests/test_component_state_io.cpp @@ -452,7 +452,7 @@ static void testGoldenFullBlobFixture() { 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0x00,0x00, 0x00,0x05,0x00,0x00,0x00,0x53,0x6e,0x61,0x72,0x65,0x13,0x00,0x00,0x00,0x67,0x75, 0x69,0x64,0x2d,0x31,0x32,0x33,0x34,0x2d,0x35,0x36,0x37,0x38,0x2d,0x61,0x62,0x63, - 0x64,0x04,0x00,0x00,0x00,0x6b,0x69,0x63,0x6b,0x00,0xff,0xff,0xff,0x0c,0x00,0x00, + 0x64,0x04,0x00,0x00,0x00,0x6b,0x69,0x63,0x6b,0x00,0xff,0xff,0xff,0x0d,0x00,0x00, 0x00,0x01,0x24,0x00,0x00,0x00,0x01,0x01,0xe8,0x03,0x00,0x00,0x00,0x00,0x00,0x00, 0x88,0x13,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0xfa,0x00,0x00,0x00,0x00,0x00,0x00, 0x00,0x01,0x9a,0x99,0x99,0x99,0x99,0x99,0xa9,0x3f,0x00,0x00,0x00,0x00,0x00,0x00, @@ -515,6 +515,36 @@ static void testGoldenFullBlobFixture() { 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // value 0.0 0x00,0x00,0x00,0x00,0x00,0xc0,0x5f,0x40, // velocity 127.0 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // value 0.0 + // --- payload v13 dual Staged/Spline state. Three spline EGs (amp, pitch, filter), + // each Staged with the y = 1 - x default contour, then the three velocity curves' + // hard-flag tails, all flags clear --- + 0x00, // amp: Staged + 0x02,0x00,0x00,0x00, // 2 points + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // x 0.0 + 0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // y 1.0 + 0x00, // smooth + 0x00,0x00,0x00,0x00,0x00,0xc0,0x5f,0x40, // x 127.0 + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // y 0.0 + 0x00, // smooth + 0x00, // pitch: Staged + 0x02,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, + 0x00, + 0x00,0x00,0x00,0x00,0x00,0xc0,0x5f,0x40, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00, + 0x00, // filter: Staged + 0x02,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, + 0x00, + 0x00,0x00,0x00,0x00,0x00,0xc0,0x5f,0x40, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00, + 0x03,0x00,0x00,0x00, 0x00,0x00,0x00, // amp curve hard flags (3 points) + 0x02,0x00,0x00,0x00, 0x00,0x00, // filter curve hard flags + 0x02,0x00,0x00,0x00, 0x00,0x00, // pitch curve hard flags }; // clang-format on CHECK(bytes.size() == sizeof(kGolden)); @@ -562,17 +592,18 @@ static void testEnvelopePrefixBytesFrozen() { CHECK(bytes[4] == 0); // ChannelMode::Mono } CHECK(kComponentStateVersion == 11); - CHECK(kParamsPayloadVersion == 12); + CHECK(kParamsPayloadVersion == 13); CHECK(kParamsSingleRecordVersion == 8); CHECK(kParamsFormatMarker == 0xFFFFFF00u); - // The filter, staged-curve, loop and velocity tails rode PAYLOAD bumps, not envelope ones - // — the two axes stay independent, so a future envelope field cannot collide with any of - // them on one number. + // The filter, staged-curve, loop, velocity and spline tails rode PAYLOAD bumps, not + // envelope ones — the two axes stay independent, so a future envelope field cannot collide + // with any of them on one number. CHECK(kParamsFilterVersion > kParamsSingleRecordVersion); CHECK(kParamsCurveVersion > kParamsFilterVersion); CHECK(kParamsLoopVersion > kParamsCurveVersion); CHECK(kParamsVelocityVersion > kParamsLoopVersion); - CHECK(kParamsPayloadVersion == kParamsVelocityVersion); + CHECK(kParamsSplineVersion > kParamsVelocityVersion); + CHECK(kParamsPayloadVersion == kParamsSplineVersion); } // --- The filter tail (payload v9) -------------------------------------------- @@ -1460,11 +1491,12 @@ static void testSampleRefsTruncatedMidEntry() { // the current params payload for DEFAULT params (marker4+version4 + overrides3 + the // 91-byte play tail + keyTrack8 + curve(4+2*16, the flat 2-point default) + the 134-byte // v9 filter tail + the 152-byte v10 staged-curve tail + the 8-byte v11 crossfade + the - // 36-byte v12 pitch curve) = 488 bytes; entry two is 47 bytes (id 4+3, path 4+7, root4, - // loop 1+8+8, channels4, name 4+0). Cutting 508 keeps the first 27 of entry two's 47 — - // mid loop.start (offset 23..31). - CHECK(bytes.size() > 508); - bytes.resize(bytes.size() - 508); + // 36-byte v12 pitch curve + the 135-byte v13 dual-state tail, three 39-byte spline EGs and + // three 6-byte hard-flag tails) = 623 bytes; entry two is 47 bytes (id 4+3, path 4+7, + // root4, loop 1+8+8, channels4, name 4+0). Cutting 643 keeps the first 27 of entry two's + // 47 — mid loop.start (offset 23..31). + CHECK(bytes.size() > 643); + bytes.resize(bytes.size() - 643); const ComponentState back = deserializeComponentState(bytes, 44100.0); CHECK(back.sampleRefs.size() == 1); CHECK(back.sampleRefs.size() == 1 && back.sampleRefs[0].sampleId == "kick"); diff --git a/tests/test_deck_groups.cpp b/tests/test_deck_groups.cpp index a5d9133..1f831a2 100644 --- a/tests/test_deck_groups.cpp +++ b/tests/test_deck_groups.cpp @@ -366,6 +366,7 @@ static void testEveryDeckControlIsClassifiedLiveOrReloading() { DeckParam::kAmpVelCurve, DeckParam::kPitchVelCurve, DeckParam::kFilterVelCurve, DeckParam::kKeyTrack, DeckParam::kTrigLength, DeckParam::kAmpEnvSelect, DeckParam::kPitchEnvSelect, DeckParam::kFilterEnvSelect, + DeckParam::kAmpEnvMode, DeckParam::kPitchEnvMode, DeckParam::kFilterEnvMode, DeckParam::kVoiceCount, DeckParam::kVoiceMode, DeckParam::kMonoTrigger, DeckParam::kMasterGain, }; @@ -450,16 +451,36 @@ static void testANonRadioIdLeavesTheOverlaySelectionAlone() { CHECK(nextOverlaySelection(OverlayEnv::kAmp, 9999) == OverlayEnv::kAmp); } +// The two group gates, spelled the way the predicates read them. Spline flags default off, so +// a case that says nothing about them is asserting the staged behaviour. +static DeckEnableState gates(bool pitchEnv, bool filter) { + DeckEnableState s; + s.pitchEnvEnabled = pitchEnv; + s.filterEnabled = filter; + return s; +} + // An overlay whose deck group is switched OFF is inert, matching the drawn-but-dead knobs on // the same params: a node drag must not reach a value the knob refuses. static void testOverlayIsInertExactlyWhenItsGroupToggleIsOff() { - CHECK(overlayEnvInert(OverlayEnv::kPitch, /*pitchEnv=*/false, /*filter=*/true)); - CHECK(!overlayEnvInert(OverlayEnv::kPitch, true, true)); - CHECK(overlayEnvInert(OverlayEnv::kFilter, true, /*filter=*/false)); - CHECK(!overlayEnvInert(OverlayEnv::kFilter, true, true)); + CHECK(overlayEnvInert(OverlayEnv::kPitch, gates(/*pitchEnv=*/false, /*filter=*/true))); + CHECK(!overlayEnvInert(OverlayEnv::kPitch, gates(true, true))); + CHECK(overlayEnvInert(OverlayEnv::kFilter, gates(true, /*filter=*/false))); + CHECK(!overlayEnvInert(OverlayEnv::kFilter, gates(true, true))); // Amp has no enable toggle, so it is never inert; kNone draws nothing to grab. - CHECK(!overlayEnvInert(OverlayEnv::kAmp, false, false)); - CHECK(!overlayEnvInert(OverlayEnv::kNone, false, false)); + CHECK(!overlayEnvInert(OverlayEnv::kAmp, gates(false, false))); + CHECK(!overlayEnvInert(OverlayEnv::kNone, gates(false, false))); + + // The enable gate alone, which the SPLINE overlay reads: it survives a mode switch, so a + // disabled group's contour is as dead as its knobs. + CHECK(!overlayEnvEnabled(OverlayEnv::kPitch, gates(false, true))); + CHECK(overlayEnvEnabled(OverlayEnv::kAmp, gates(false, false))); + // ...while the staged overlay additionally goes inert once the envelope is drawn: its + // nodes are no longer what the overlay is editing. + DeckEnableState drawn = gates(true, true); + drawn.ampSpline = true; + CHECK(overlayEnvInert(OverlayEnv::kAmp, drawn)); + CHECK(overlayEnvEnabled(OverlayEnv::kAmp, drawn)); } // A deck knob goes inert exactly with its group's own enable toggle — including the filter's @@ -467,16 +488,45 @@ static void testOverlayIsInertExactlyWhenItsGroupToggleIsOff() { // go inert with the rest of the filter (the reachable-through-the-deck route mouseDownDeck // checks before ever routing a curve-cell click to the popup). static void testDeckKnobIsInertExactlyWithItsGroupsEnableToggle() { - CHECK(deckKnobInert(DeckParam::kFilterVelCurve, /*pitchEnv=*/true, /*filter=*/false)); - CHECK(!deckKnobInert(DeckParam::kFilterVelCurve, true, true)); - CHECK(deckKnobInert(DeckParam::kFilterCutoff, true, false)); - CHECK(!deckKnobInert(DeckParam::kFilterCutoff, true, true)); - CHECK(deckKnobInert(DeckParam::kPitchEnvDepth, /*pitchEnv=*/false, true)); - CHECK(!deckKnobInert(DeckParam::kPitchEnvDepth, true, true)); + CHECK(deckKnobInert(DeckParam::kFilterVelCurve, gates(/*pitchEnv=*/true, /*filter=*/false))); + CHECK(!deckKnobInert(DeckParam::kFilterVelCurve, gates(true, true))); + CHECK(deckKnobInert(DeckParam::kFilterCutoff, gates(true, false))); + CHECK(!deckKnobInert(DeckParam::kFilterCutoff, gates(true, true))); + CHECK(deckKnobInert(DeckParam::kPitchEnvDepth, gates(/*pitchEnv=*/false, true))); + CHECK(!deckKnobInert(DeckParam::kPitchEnvDepth, gates(true, true))); // The amp's own velocity cell and every ordinary control are never inert here — inertness - // is a filter/pitch-env-group-only concept. - CHECK(!deckKnobInert(DeckParam::kAmpVelCurve, false, false)); - CHECK(!deckKnobInert(DeckParam::kAttack, false, false)); + // is a filter/pitch-env-group-only concept until an envelope is drawn. + CHECK(!deckKnobInert(DeckParam::kAmpVelCurve, gates(false, false))); + CHECK(!deckKnobInert(DeckParam::kAttack, gates(false, false))); +} + +// A drawn envelope's STAGED segment knobs go inert; the mode toggle itself and the depth knobs +// that scale either shape stay live. (Which segment knobs, per envelope, is pinned in +// spline_egs_tests alongside the rest of the spline rules.) +static void testAModeToggleIsNeitherLiveNorAnOverlayRadio() { + CHECK(!isLiveDeckParam(DeckParam::kAmpEnvMode)); + CHECK(!isLiveDeckParam(DeckParam::kPitchEnvMode)); + CHECK(!isLiveDeckParam(DeckParam::kFilterEnvMode)); + CHECK(overlayEnvForModeToggle(radio(DeckParam::kAmpEnvMode)) == OverlayEnv::kAmp); + CHECK(overlayEnvForModeToggle(radio(DeckParam::kPitchEnvMode)) == OverlayEnv::kPitch); + CHECK(overlayEnvForModeToggle(radio(DeckParam::kFilterEnvMode)) == OverlayEnv::kFilter); + // A mode toggle must not be mistaken for the overlay-select radio beside it. + CHECK(overlayEnvForRadio(radio(DeckParam::kAmpEnvMode)) == OverlayEnv::kNone); + CHECK(overlayEnvForModeToggle(radio(DeckParam::kAmpEnvSelect)) == OverlayEnv::kNone); +} + +// The three mode toggles ride each env group's caption slack, so the deck's wrapped geometry +// is unchanged by them: raising their segment width past the caption headroom would reflow the +// first row and push the deck to a fourth one (see testDeckFitsInsideTheEnforcedMinimumWindow). +static void testTheModeTogglesCostNoGroupWidth() { + for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) { + for (const DeckGroupDesc& g : sampleDeckGroups(mode)) { + if (g.captionToggle2.id < 0) continue; + DeckGroupDesc without = g; + without.captionToggle2 = DeckToggleDesc{}; + CHECK(deckGroupWidth(g) == deckGroupWidth(without)); + } + } } int main() { @@ -485,6 +535,8 @@ int main() { testANonRadioIdLeavesTheOverlaySelectionAlone(); testOverlayIsInertExactlyWhenItsGroupToggleIsOff(); testDeckKnobIsInertExactlyWithItsGroupsEnableToggle(); + testAModeToggleIsNeitherLiveNorAnOverlayRadio(); + testTheModeTogglesCostNoGroupWidth(); testEveryDeckControlIsClassifiedLiveOrReloading(); testOnlyALiveControlsDragTakesTheLiveTier(); testDeckReadsPitchThenFilterThenAmpLeftToRight(); diff --git a/tests/test_knob_deck.cpp b/tests/test_knob_deck.cpp index 97e1c43..e155065 100644 --- a/tests/test_knob_deck.cpp +++ b/tests/test_knob_deck.cpp @@ -26,27 +26,27 @@ static int g_fail = 0; // toggle + row toggle), MASTER (1 cell, no toggle). static std::vector shellLikeDeck() { std::vector g; - g.push_back({0, 78, {}, {100, 44}, {1, 2, 3, 4, 5}, {}}); - g.push_back({1, 38, {}, {101, 48}, {6}, {}}); - g.push_back({2, 58, {}, {102, 32}, {7, 8, 9}, {}}); - g.push_back({3, 38, {}, {103, 40}, {10}, {104, 44}}); - g.push_back({4, 46, {}, {}, {11}, {}}); + g.push_back({0, 78, {}, {100, 44}, {}, {1, 2, 3, 4, 5}, {}}); + g.push_back({1, 38, {}, {101, 48}, {}, {6}, {}}); + g.push_back({2, 58, {}, {102, 32}, {}, {7, 8, 9}, {}}); + g.push_back({3, 38, {}, {103, 40}, {}, {10}, {104, 44}}); + g.push_back({4, 46, {}, {}, {}, {11}, {}}); return g; } static void testGroupWidth() { // Knob row dominates: 5 cells (240) > caption row (78 + 4 + 88 = 170) -> 240 + 2*6. - DeckGroupDesc amp{0, 78, {}, {100, 44}, {1, 2, 3, 4, 5}, {}}; + DeckGroupDesc amp{0, 78, {}, {100, 44}, {}, {1, 2, 3, 4, 5}, {}}; CHECK(deckGroupWidth(amp) == 5 * kDeckCellW + 2 * kDeckGroupPadX); // Caption row dominates: 38 + 4 + 96 = 138 > 48 -> 138 + 12. - DeckGroupDesc pitch{1, 38, {}, {101, 48}, {6}, {}}; + DeckGroupDesc pitch{1, 38, {}, {101, 48}, {}, {6}, {}}; CHECK(deckGroupWidth(pitch) == 38 + kDeckToggleGap + 2 * 48 + 2 * kDeckGroupPadX); // Row toggle counts into the knob row: 48 + 4 + 88 = 140 > caption 38+4+80=122. - DeckGroupDesc voice{3, 38, {}, {103, 40}, {10}, {104, 44}}; + DeckGroupDesc voice{3, 38, {}, {103, 40}, {}, {10}, {104, 44}}; CHECK(deckGroupWidth(voice) == kDeckCellW + kDeckToggleGap + 2 * 44 + 2 * kDeckGroupPadX); // No toggles: max(caption, cells) + padding. - DeckGroupDesc master{4, 46, {}, {}, {11}, {}}; + DeckGroupDesc master{4, 46, {}, {}, {}, {11}, {}}; CHECK(deckGroupWidth(master) == kDeckCellW + 2 * kDeckGroupPadX); } @@ -148,7 +148,7 @@ static void testHitTest() { // A blank cell (id -1) misses even though its rect exists. std::vector trig; - trig.push_back({0, 78, {}, {100, 44}, {20, 21, 22, -1, -1}, {}}); + trig.push_back({0, 78, {}, {100, 44}, {}, {20, 21, 22, -1, -1}, {}}); const DeckLayout tl = layoutDeck(trig, 0, 0, 824); const DeckCellLayout& blank = tl.groups[0].cells[4]; CHECK(blank.id == -1); @@ -165,8 +165,8 @@ static void testHitTest() { // The corner radio widens the caption row, takes the far corner, and pushes the caption // toggle left of itself — the three properties the overlay-select switch relies on. static void testCaptionRadioGeometryAndHit() { - const DeckGroupDesc bare{7, 78, {}, {200, 44}, {1, 2}, {}}; - const DeckGroupDesc withRadio{7, 78, {201}, {200, 44}, {1, 2}, {}}; + const DeckGroupDesc bare{7, 78, {}, {200, 44}, {}, {1, 2}, {}}; + const DeckGroupDesc withRadio{7, 78, {201}, {200, 44}, {}, {1, 2}, {}}; // Caption row grows by exactly gap + radio; the knob row is unchanged, so a group whose // caption row already dominated grows by that much. CHECK(deckGroupWidth(withRadio) - deckGroupWidth(bare) == diff --git a/tests/test_spline_edit.cpp b/tests/test_spline_edit.cpp new file mode 100644 index 0000000..d9ba2e7 --- /dev/null +++ b/tests/test_spline_edit.cpp @@ -0,0 +1,110 @@ +// Standalone tests for reasampler::instrument::ui::spline_edit — no VST3, no REAPER, no +// framework. Asserts the ONE point-editing grammar both spline consumers route through: +// left-click grabs a node and adds in empty space, right-click deletes, control-click toggles +// hard/smooth, and a click outside the mapping box resolves to nothing unless it lands on a +// node's pick radius (so the popup's inset ring can grab but never add). + +#include "../src/core/instrument/ui/spline_edit.h" + +#include + +using namespace reasampler::instrument::ui; +using namespace reasampler::instrument::engine; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +static const VelocityCurve::Box kBox{100, 50, 127, 101}; + +// A three-point curve whose interior node sits well away from both endpoints. +static VelocityCurve threePoint() { + VelocityCurve c = VelocityCurve::rampDown(); + c.addPoint(64.0, 0.5); + return c; +} + +static void testLeftClickOnANodeGrabsIt() { + const VelocityCurve c = threePoint(); + const auto px = c.pixelFromPoint(kBox, c.points()[1]); + const SplineEdit e = resolveSplineEdit(c, kBox, SplineGesture::kLeft, px.x, px.y); + CHECK(e.kind == SplineEditKind::kGrab); + CHECK(e.index == 1); +} + +static void testLeftClickInEmptySpaceAdds() { + const VelocityCurve c = VelocityCurve::rampDown(); + // Well away from either endpoint's drawn position and from the traced line's nodes. + const SplineEdit e = resolveSplineEdit(c, kBox, SplineGesture::kLeft, 160, 60); + CHECK(e.kind == SplineEditKind::kAdd); + CHECK(e.index == -1); // no point yet — the caller creates it +} + +static void testRightClickOnANodeDeletesAndElsewhereDoesNothing() { + const VelocityCurve c = threePoint(); + const auto px = c.pixelFromPoint(kBox, c.points()[1]); + const SplineEdit hit = resolveSplineEdit(c, kBox, SplineGesture::kRight, px.x, px.y); + CHECK(hit.kind == SplineEditKind::kDelete); + CHECK(hit.index == 1); + // Right-click on empty canvas must NOT add — delete is the only thing the gesture means. + const SplineEdit miss = resolveSplineEdit(c, kBox, SplineGesture::kRight, 160, 60); + CHECK(miss.kind == SplineEditKind::kNone); +} + +static void testControlClickOnANodeTogglesHardAndElsewhereDoesNothing() { + const VelocityCurve c = threePoint(); + const auto px = c.pixelFromPoint(kBox, c.points()[1]); + const SplineEdit hit = resolveSplineEdit(c, kBox, SplineGesture::kControlLeft, px.x, px.y); + CHECK(hit.kind == SplineEditKind::kToggleHard); + CHECK(hit.index == 1); + const SplineEdit miss = resolveSplineEdit(c, kBox, SplineGesture::kControlLeft, 160, 60); + CHECK(miss.kind == SplineEditKind::kNone); +} + +// The popup draws its box inset inside a border; a click in that ring must be able to GRAB an +// endpoint handle (which is drawn on the box edge, within the pick radius of the ring) but must +// never ADD — an added point there clamps onto an endpoint's x and stacks an undeletable +// duplicate. +static void testOutsideTheBoxGrabsButNeverAdds() { + const VelocityCurve c = VelocityCurve::rampDown(); + const auto first = c.pixelFromPoint(kBox, c.points()[0]); + const SplineEdit ring = + resolveSplineEdit(c, kBox, SplineGesture::kLeft, first.x - 3, first.y - 3); + CHECK(ring.kind == SplineEditKind::kGrab); + CHECK(ring.index == 0); + // Far outside, on no node at all. + const SplineEdit away = resolveSplineEdit(c, kBox, SplineGesture::kLeft, kBox.left - 60, + kBox.top - 40); + CHECK(away.kind == SplineEditKind::kNone); +} + +static void testDegenerateBoxResolvesToNothing() { + const VelocityCurve c = threePoint(); + CHECK(resolveSplineEdit(c, VelocityCurve::Box{0, 0, 0, 40}, SplineGesture::kLeft, 0, 0) + .kind == SplineEditKind::kNone); + CHECK(resolveSplineEdit(c, VelocityCurve::Box{0, 0, 40, 1}, SplineGesture::kLeft, 0, 0) + .kind == SplineEditKind::kNone); +} + +// The overlay's box is the FULL area — no inset — so the contour spans the sample's whole +// drawn width 1:1 with its time axis. +static void testOverlayBoxIsTheWholeArea() { + const OverlayArea area{Rect::ltrb(12, 40, 812, 240)}; + const VelocityCurve::Box box = splineOverlayBox(area); + CHECK(box.left == 12); + CHECK(box.top == 40); + CHECK(box.width == 800); + CHECK(box.height == 200); +} + +int main() { + testLeftClickOnANodeGrabsIt(); + testLeftClickInEmptySpaceAdds(); + testRightClickOnANodeDeletesAndElsewhereDoesNothing(); + testControlClickOnANodeTogglesHardAndElsewhereDoesNothing(); + testOutsideTheBoxGrabsButNeverAdds(); + testDegenerateBoxResolvesToNothing(); + testOverlayBoxIsTheWholeArea(); + if (g_fail == 0) std::printf("spline_edit: all tests passed\n"); + return g_fail == 0 ? 0 : 1; +} diff --git a/tests/test_spline_egs.cpp b/tests/test_spline_egs.cpp new file mode 100644 index 0000000..4d82990 --- /dev/null +++ b/tests/test_spline_egs.cpp @@ -0,0 +1,424 @@ +// Standalone tests for the SPLINE EG system — no VST3, no REAPER, no framework. One file for +// the whole feature because its seams span three modules that only mean something together: the +// shared spline (engine), the dual Staged/Spline state and its wire format (map), and the +// point-editing grammar (ui). +// +// The eleven cases below are the spec's own test list, in its order. Each is named for the rule +// it pins, so a failure names the behaviour rather than the module. + +#include "../src/core/instrument/engine/voice_engine.h" +#include "../src/core/instrument/map/component_state_io.h" +#include "../src/core/instrument/ui/deck_groups.h" +#include "../src/core/instrument/ui/spline_edit.h" + +#include +#include +#include + +using namespace reasampler; +using namespace reasampler::instrument::engine; +using reasampler::instrument::map::ComponentState; +using reasampler::instrument::map::InstrumentParams; +using reasampler::instrument::map::PlaySeconds; +using reasampler::instrument::map::deserializeComponentState; +using reasampler::instrument::map::kParamsFormatMarker; +using reasampler::instrument::map::kParamsPayloadVersion; +using reasampler::instrument::map::resolvePlay; +using reasampler::instrument::map::serializeComponentState; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +static bool near(double a, double b, double eps = 1e-9) { return std::fabs(a - b) <= eps; } + +// x runs over the curve's canonical span; a spline EG's phase maps onto it linearly. +static double xAt(double phase) { return kCurveXMin + phase * (kCurveXMax - kCurveXMin); } + +// A contour that rises then falls, with a peak at x=64 the caller can make hard or smooth. +static VelocityCurve peakContour(bool hardPeak) { + VelocityCurve c = VelocityCurve::fromPoints( + {{0.0, 0.0}, {32.0, 0.2}, {64.0, 1.0}, {96.0, 0.3}, {127.0, 0.0}}, + CurveDomain::Unipolar); + if (hardPeak) c.setHard(2, true); + return c; +} + +// --- 1. A hard point is a genuine slope discontinuity ------------------------- + +// The whole hard-point enhancement in one assertion: at a hard knot each side's one-sided slope +// is that SEGMENT'S OWN secant — no smoothing was applied on either side — so the two disagree +// and the contour has a real corner. The same knot left smooth pins to a shared tangent. +static void testHardPointGivesDifferingOneSidedSlopes() { + const VelocityCurve hard = peakContour(/*hardPeak=*/true); + const double h = 1e-4; + const double left = (hard.eval(64.0) - hard.eval(64.0 - h)) / h; + const double right = (hard.eval(64.0 + h) - hard.eval(64.0)) / h; + // The two adjacent secants: (1.0-0.2)/32 rising, (0.3-1.0)/32 falling. + const double secantIn = (1.0 - 0.2) / 32.0; + const double secantOut = (0.3 - 1.0) / 32.0; + CHECK(near(left, secantIn, 1e-4)); + CHECK(near(right, secantOut, 1e-4)); + CHECK(left > 0.0 && right < 0.0); // a genuine corner, not a slope that merely changes rate + + // Neither adjacent segment is straightened by the hard knot — each is still a curve, which + // is what "one or more monotone splines joined at their angles" means. A straight segment + // would put its midpoint exactly on the chord. + CHECK(!near(hard.eval(48.0), (0.2 + 1.0) / 2.0, 1e-6)); + CHECK(!near(hard.eval(80.0), (1.0 + 0.3) / 2.0, 1e-6)); + + // Left smooth, the same knot is a local extremum: Fritsch-Carlson pins the tangent to 0 on + // BOTH sides, so the slope is continuous there. + const VelocityCurve smooth = peakContour(/*hardPeak=*/false); + const double sLeft = (smooth.eval(64.0) - smooth.eval(64.0 - h)) / h; + const double sRight = (smooth.eval(64.0 + h) - smooth.eval(64.0)) / h; + CHECK(near(sLeft, 0.0, 1e-4)); + CHECK(near(sRight, 0.0, 1e-4)); +} + +// --- 2. Per-segment monotonicity, on a contour that is not globally monotone --- + +static void testNoOvershootBetweenAnyAdjacentPairOnARiseAndFallContour() { + VelocityCurve c = VelocityCurve::fromPoints( + {{0.0, 0.2}, {20.0, 0.9}, {50.0, 0.1}, {90.0, 0.85}, {110.0, 0.15}, {127.0, 0.6}}, + CurveDomain::Unipolar); + c.setHard(2, true); // one hard knot, so the guarantee is asserted across a joint too + const std::vector& pts = c.points(); + for (std::size_t i = 0; i + 1 < pts.size(); ++i) { + const double lo = (std::min)(pts[i].value, pts[i + 1].value); + const double hi = (std::max)(pts[i].value, pts[i + 1].value); + for (int s = 0; s <= 200; ++s) { + const double x = pts[i].velocity + + (pts[i + 1].velocity - pts[i].velocity) * (s / 200.0); + const double y = c.eval(x); + CHECK(y >= lo - 1e-12); + CHECK(y <= hi + 1e-12); + } + } + // ...and it genuinely rises AND falls, so the assertion above is not vacuously about a + // monotone curve. + CHECK(c.eval(20.0) > c.eval(0.0)); + CHECK(c.eval(50.0) < c.eval(20.0)); +} + +// --- 3. The 128-point ceiling refuses without disturbing the contour ---------- + +static void testAddingAtTheCeilingIsRefusedAndLeavesTheContourBitIdentical() { + VelocityCurve c = VelocityCurve::rampDown(); + for (std::size_t i = 0; c.size() < kMaxCurvePoints; ++i) { + const double x = 1.0 + static_cast(i); + CHECK(c.addPoint(x, 0.5) >= 0); + } + CHECK(c.size() == kMaxCurvePoints); + const std::vector before = c.points(); + CHECK(c.addPoint(63.5, 0.25) == -1); + const std::vector& after = c.points(); + CHECK(after.size() == before.size()); + for (std::size_t i = 0; i < before.size(); ++i) { + // Bit-identical, not merely close: a refused add must not perturb a drawn shape at all. + CHECK(after[i].velocity == before[i].velocity); + CHECK(after[i].value == before[i].value); + CHECK(after[i].hard == before[i].hard); + } +} + +// --- 4. The two full-length endpoints always survive -------------------------- + +static void testEndpointDeletionIsRefused() { + VelocityCurve c = peakContour(false); + const std::size_t n = c.size(); + CHECK(!c.deletePoint(0)); + CHECK(!c.deletePoint(n - 1)); + CHECK(c.size() == n); + CHECK(c.points().front().velocity == kCurveXMin); + CHECK(c.points().back().velocity == kCurveXMax); + // An interior point still deletes, so the refusal is about the endpoints and not about + // deletion being broken. + CHECK(c.deletePoint(2)); + CHECK(c.size() == n - 1); +} + +// --- 5. The hard/smooth toggle round-trips ------------------------------------ + +static void testTogglingHardThenSmoothRestoresTheEvaluatedContour() { + VelocityCurve c = peakContour(false); + std::vector baseline; + for (int i = 0; i <= 127; ++i) baseline.push_back(c.eval(i)); + + CHECK(c.toggleHard(2)); + bool changedSomewhere = false; + for (int i = 0; i <= 127; ++i) { + if (!near(c.eval(i), baseline[static_cast(i)], 1e-12)) changedSomewhere = true; + } + CHECK(changedSomewhere); // the toggle must actually do something, or the round trip is empty + CHECK(c.points()[2].hard); + + CHECK(c.toggleHard(2)); + CHECK(!c.points()[2].hard); + for (int i = 0; i <= 127; ++i) { + CHECK(c.eval(i) == baseline[static_cast(i)]); // exact, not approximate + } +} + +// --- 6. Both states survive a mode flip, in memory and across save/reload ----- + +// Distinctive staged values on all three envelopes, so "exactly as left" is checkable rather +// than accidentally equal to a default. +static InstrumentParams paramsWithBothStates() { + InstrumentParams p; + p.play.playMode = PlayMode::Gate; + p.play.adsr.attackSeconds = 0.37; + p.play.adsr.decaySeconds = 0.21; + p.play.adsr.sustainLevel = 0.42; + p.play.adsr.releaseSeconds = 0.66; + p.play.pitchEnv.enabled = true; + p.play.pitchEnv.peakSemitones = -7.5; + p.play.pitchEnv.shape.attackSeconds = 0.11; + p.play.filter.enabled = true; + p.play.filter.env.decaySeconds = 0.29; + p.play.filter.env.sustainLevel = 0.33; + + VelocityCurve drawn = peakContour(/*hardPeak=*/true); + p.play.ampSpline.mode = EnvMode::Spline; + p.play.ampSpline.contour = drawn; + p.play.pitchSpline.contour = drawn; // stored, but left Staged: the inactive half + p.play.filterSpline.contour = drawn; + return p; +} + +static void testStagedAndSplineStatesBothSurviveAFlipAndASaveReload() { + InstrumentParams p = paramsWithBothStates(); + const VelocityCurve drawn = p.play.ampSpline.contour; + + // In memory: flipping the amp back to Staged keeps the contour, and forward again keeps the + // staged values. Neither converts into the other. + p.play.ampSpline.mode = EnvMode::Staged; + CHECK(p.play.ampSpline.contour.equals(drawn)); + CHECK(p.play.adsr.attackSeconds == 0.37); + p.play.ampSpline.mode = EnvMode::Spline; + CHECK(p.play.adsr.sustainLevel == 0.42); + + ComponentState st; + st.selectionId = "cap-1"; + st.params = p; + const ComponentState back = deserializeComponentState(serializeComponentState(st), 48000.0); + const PlaySeconds& r = back.params.play; + + CHECK(r.adsr.attackSeconds == 0.37); + CHECK(r.adsr.decaySeconds == 0.21); + CHECK(r.adsr.sustainLevel == 0.42); + CHECK(r.adsr.releaseSeconds == 0.66); + CHECK(r.pitchEnv.peakSemitones == -7.5); + CHECK(r.pitchEnv.shape.attackSeconds == 0.11); + CHECK(r.filter.env.decaySeconds == 0.29); + CHECK(r.filter.env.sustainLevel == 0.33); + + CHECK(r.ampSpline.mode == EnvMode::Spline); + CHECK(r.pitchSpline.mode == EnvMode::Staged); + CHECK(r.filterSpline.mode == EnvMode::Staged); + // The contour itself, hard flags included, on all three — the inactive ones too. + CHECK(r.ampSpline.contour.equals(drawn)); + CHECK(r.pitchSpline.contour.equals(drawn)); + CHECK(r.filterSpline.contour.equals(drawn)); + CHECK(r.ampSpline.contour.points()[2].hard); +} + +// --- 7. A v12 payload still loads --------------------------------------------- + +// Rewrites the params-payload version field to 12, leaving the v12 prefix byte-identical (v13 +// is a strict suffix, so the prefix IS what a v12 writer emitted). The reader is positional and +// bounded, so it stops before the appended tail and never sees it. +static std::vector asV12Payload(std::vector bytes) { + int patched = 0; + for (std::size_t i = 0; i + 8 <= bytes.size(); ++i) { + const std::uint32_t marker = static_cast(bytes[i]) | + (static_cast(bytes[i + 1]) << 8) | + (static_cast(bytes[i + 2]) << 16) | + (static_cast(bytes[i + 3]) << 24); + const std::uint32_t ver = static_cast(bytes[i + 4]) | + (static_cast(bytes[i + 5]) << 8) | + (static_cast(bytes[i + 6]) << 16) | + (static_cast(bytes[i + 7]) << 24); + if (marker != kParamsFormatMarker || ver != kParamsPayloadVersion) continue; + bytes[i + 4] = 12; + ++patched; + } + CHECK(patched == 1); // exactly one payload header, or the rewrite is meaningless + return bytes; +} + +static void testAV12PayloadLoadsWithoutLoss() { + InstrumentParams p = paramsWithBothStates(); + p.play.playMode = PlayMode::Trigger; // a v12 project can hold any mode + p.velocityCurve.addPoint(70.0, 0.4); + p.velocityCurve.setHard(1, true); + ComponentState st; + st.selectionId = "cap-legacy"; + st.params = p; + + const ComponentState back = + deserializeComponentState(asV12Payload(serializeComponentState(st)), 48000.0); + const PlaySeconds& r = back.params.play; + + // Everything v12 carried comes through untouched. + CHECK(back.selectionId == "cap-legacy"); + CHECK(r.playMode == PlayMode::Trigger); + CHECK(r.adsr.attackSeconds == 0.37); + CHECK(r.adsr.sustainLevel == 0.42); + CHECK(r.pitchEnv.peakSemitones == -7.5); + CHECK(r.filter.env.decaySeconds == 0.29); + CHECK(back.params.velocityCurve.size() == 3); + CHECK(near(back.params.velocityCurve.eval(70.0), 0.4)); + + // Everything v13 added lifts to its default: Staged on all three, the y = 1 - x contour, + // and no hard flag anywhere (v12 had nowhere to store one). + CHECK(r.ampSpline.mode == EnvMode::Staged); + CHECK(r.pitchSpline.mode == EnvMode::Staged); + CHECK(r.filterSpline.mode == EnvMode::Staged); + CHECK(r.ampSpline.contour.equals(VelocityCurve::rampDown())); + CHECK(!back.params.velocityCurve.points()[1].hard); +} + +// --- 8. A stored contour rescales to a different-length sample --------------- + +static SampleData splineAmpSample(std::size_t frames, const VelocityCurve& contour) { + SampleData s; + s.frames.assign(frames, 1.0f); // DC: the rendered value IS the envelope + s.rootNote = 60; + s.sampleRate = 48000; + s.play.playMode = PlayMode::Trigger; + s.play.ampSpline.mode = EnvMode::Spline; + s.play.ampSpline.contour = contour; + return s; +} + +static std::vector renderVoice(const SampleData& s, std::size_t frames) { + Voice v; + v.start(60, 100, s); + std::vector out; + out.reserve(frames); + for (std::size_t i = 0; i < frames; ++i) out.push_back(v.renderFrame()); + return out; +} + +static void testAContourReplaysProportionallyOnADifferentLengthSample() { + const VelocityCurve contour = peakContour(/*hardPeak=*/true); + const std::size_t shortLen = 1000; + const std::size_t longLen = 3000; + const SampleData s1 = splineAmpSample(shortLen, contour); + const SampleData s2 = splineAmpSample(longLen, contour); + const std::vector a = renderVoice(s1, shortLen); + const std::vector b = renderVoice(s2, longLen); + + // The rendered value at frame i of the short sample is the contour at phase i/shortLen; the + // long sample reaches the SAME phase at frame 3i. Shape preserved, proportionally. + for (std::size_t i = 1; i + 1 < shortLen; ++i) { + const double phase = static_cast(i) / static_cast(shortLen); + CHECK(near(a[i], contour.eval(xAt(phase)), 1e-6)); + CHECK(near(b[i * 3], a[i], 1e-6)); + } + // Not a flat contour, so the agreement above is a real shape match. + CHECK(a[shortLen / 2] > a[10] + 0.2); +} + +// --- 9. A fresh spline EG opens on the smooth y = 1 - x ---------------------- + +static void testAFreshSplineEgDefaultsToTheSmoothDownwardSlope() { + const SplineEnv fresh; + CHECK(fresh.mode == EnvMode::Staged); // drawn is opt-in; the CONTOUR is what defaults here + const VelocityCurve& c = fresh.contour; + CHECK(c.size() == 2); + CHECK(!c.points()[0].hard); + CHECK(!c.points()[1].hard); + // Two collinear knots reduce the Hermite tangents to the shared secant, so it is an exact + // straight line — and a straight line is smooth. + for (int i = 0; i <= 127; ++i) { + CHECK(near(c.eval(i), 1.0 - static_cast(i) / 127.0, 1e-12)); + } +} + +// --- 10. Gate is unavailable while a spline EG is active --------------------- + +// The rule has one home (splineActive) and one enforcement point on the way to the engine +// (resolvePlay). The editor's Gate segment refuses and paints Disabled off the same predicate. +static void testGateIsUnavailableWhileASplineEgIsActiveAndReturnsAfterwards() { + PlaySeconds stored; + stored.playMode = PlayMode::Gate; + CHECK(!splineActive(stored)); + CHECK(resolvePlay(stored, 48000).playMode == PlayMode::Gate); + + for (EnvMode* slot : {&stored.ampSpline.mode, &stored.pitchSpline.mode, + &stored.filterSpline.mode}) { + *slot = EnvMode::Spline; + CHECK(splineActive(stored)); + CHECK(resolvePlay(stored, 48000).playMode == PlayMode::Trigger); + *slot = EnvMode::Staged; + CHECK(!splineActive(stored)); + CHECK(resolvePlay(stored, 48000).playMode == PlayMode::Gate); + } + + // And the staged knobs of a drawn envelope go inert — drawn-but-dead, not removed — while + // its depth knob, which scales either shape, stays live. + using namespace reasampler::instrument::ui; + DeckEnableState gates; + gates.pitchEnvEnabled = true; + gates.filterEnabled = true; + CHECK(!deckKnobInert(DeckParam::kAttack, gates)); + gates.ampSpline = true; + CHECK(deckKnobInert(DeckParam::kAttack, gates)); + CHECK(deckKnobInert(DeckParam::kSustain, gates)); + CHECK(deckKnobInert(DeckParam::kTrigDecay, gates)); + gates.pitchSpline = true; + CHECK(deckKnobInert(DeckParam::kPitchEnvAttack, gates)); + CHECK(!deckKnobInert(DeckParam::kPitchEnvDepth, gates)); + gates.filterSpline = true; + CHECK(deckKnobInert(DeckParam::kFilterEnvRelease, gates)); + CHECK(!deckKnobInert(DeckParam::kFilterModAmt, gates)); +} + +// --- 11. The velocity->amp curve is the same grammar ------------------------- + +static void testTheVelocityAmpCurveGainsTheToggleAndKeepsItsDelete() { + using namespace reasampler::instrument::ui; + const VelocityCurve::Box box{100, 50, 127, 101}; + VelocityCurve amp = VelocityCurve::flat(); + CHECK(amp.addPoint(64.0, 0.25) == 1); + const auto px = amp.pixelFromPoint(box, amp.points()[1]); + + // Control-click resolves to the toggle — the identical resolution the spline EG overlay + // gets, because it is the identical function. + const SplineEdit toggle = + resolveSplineEdit(amp, box, SplineGesture::kControlLeft, px.x, px.y); + CHECK(toggle.kind == SplineEditKind::kToggleHard); + CHECK(toggle.index == 1); + const double smoothMid = amp.eval(48.0); + CHECK(amp.toggleHard(1)); + CHECK(amp.points()[1].hard); + CHECK(!near(amp.eval(48.0), smoothMid, 1e-9)); // the hard flag reaches the amp response + + // Right-click delete is unchanged: it resolves on an interior node and the endpoint guard + // still refuses the two ends. + const SplineEdit del = resolveSplineEdit(amp, box, SplineGesture::kRight, px.x, px.y); + CHECK(del.kind == SplineEditKind::kDelete); + CHECK(del.index == 1); + CHECK(amp.deletePoint(1)); + CHECK(amp.size() == 2); + CHECK(!amp.deletePoint(0)); + CHECK(!amp.deletePoint(1)); +} + +int main() { + testHardPointGivesDifferingOneSidedSlopes(); + testNoOvershootBetweenAnyAdjacentPairOnARiseAndFallContour(); + testAddingAtTheCeilingIsRefusedAndLeavesTheContourBitIdentical(); + testEndpointDeletionIsRefused(); + testTogglingHardThenSmoothRestoresTheEvaluatedContour(); + testStagedAndSplineStatesBothSurviveAFlipAndASaveReload(); + testAV12PayloadLoadsWithoutLoss(); + testAContourReplaysProportionallyOnADifferentLengthSample(); + testAFreshSplineEgDefaultsToTheSmoothDownwardSlope(); + testGateIsUnavailableWhileASplineEgIsActiveAndReturnsAfterwards(); + testTheVelocityAmpCurveGainsTheToggleAndKeepsItsDelete(); + if (g_fail == 0) std::printf("spline_egs: all tests passed\n"); + return g_fail == 0 ? 0 : 1; +}