instrument: spline EGs — hard points on the one shared spline, a drawn contour per envelope beside its staged state, payload v13
This commit is contained in:
@@ -222,7 +222,28 @@ range-clamped to the same per-param min/max the knobs enforce, so no drag can pr
|
|||||||
param a knob couldn't. Two pure modules split the forward (draw) and inverse (edit) maps —
|
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.
|
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
|
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
|
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
|
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.
|
- `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.
|
- `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()`.
|
- `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.
|
- `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/`
|
### `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`.
|
- `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.
|
- `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.
|
- `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.
|
- `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.
|
- `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.
|
- `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.
|
- `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.
|
- `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`/`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.
|
- `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.
|
- `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_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.
|
- `envelope_edit` — pure node hit-test + pixel-delta→clamped-param inverse map for the draggable envelope nodes and their curve knots (read from `envelope_edit.h`): `nodeAtPoint` resolves a grab to the nearest node within a pick radius (Chebyshev distance, draw-order tie-break, knots appended last so a coincident endpoint handle wins); `resolveNodeDrag` maps a pixel delta since grab to a new `StageEnvelope` under the same caller-supplied per-param clamp bounds the knobs use — a drag can never produce a param a knob couldn't. Mirror of `card_drag`/`waveform_view`; the inverse of `envelope_overlay`'s params→polyline forward map, so node-drag, knot-drag and knob-edit read/write one shared model and can never diverge.
|
||||||
|
|||||||
@@ -2,3 +2,8 @@ add_subdirectory(engine)
|
|||||||
add_subdirectory(map)
|
add_subdirectory(map)
|
||||||
add_subdirectory(note)
|
add_subdirectory(note)
|
||||||
add_subdirectory(ui)
|
add_subdirectory(ui)
|
||||||
|
|
||||||
|
# The spline EG spans all three: the shared curve + its RT cursor (engine), the dual-state
|
||||||
|
# persistence (map), and the point-editing grammar (ui). Declared here because no one
|
||||||
|
# subdirectory owns the seam it covers.
|
||||||
|
reasampler_test(spline_egs LINK sampler_core sample_map component_state_io spline_edit deck_groups)
|
||||||
|
|||||||
@@ -65,6 +65,20 @@ struct AhdParams {
|
|||||||
double decayCurve = util::kCurveNeutral;
|
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:
|
// 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
|
// 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.
|
// 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.
|
// baseRatio_ at note-on — it is fixed for the note's lifetime, so it costs no per-frame work.
|
||||||
VelocityCurve pitchVelocityCurve = VelocityCurve::zero();
|
VelocityCurve pitchVelocityCurve = VelocityCurve::zero();
|
||||||
FilterParams filter;
|
FilterParams filter;
|
||||||
|
// The three drawn contours: the alternative to adsr/trigAhd, to pitchEnv.shape, and to
|
||||||
|
// filter.env/trigEnv respectively. They sit HERE rather than inside the three envelope
|
||||||
|
// structs because those are copied whole into the live block, which must stay trivially
|
||||||
|
// copyable (live_params.h) — and a contour is not a live control anyway: like the velocity
|
||||||
|
// curves it travels by reload.
|
||||||
|
SplineEnv ampSpline;
|
||||||
|
SplineEnv pitchSpline;
|
||||||
|
SplineEnv filterSpline;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Whether ANY of the three envelopes is drawn rather than staged. Templated over the two
|
||||||
|
// parameter representations (frames and the editor's seconds mirror) because both spell the
|
||||||
|
// three fields identically and the rule must not be written twice — compile-time dispatch,
|
||||||
|
// no runtime cost, off every hot path.
|
||||||
|
//
|
||||||
|
// THE consequence, and its one home: a spline contour is a pure time function over the full
|
||||||
|
// sample length, which IS the Trigger/one-shot playback model — so Gate is not available while
|
||||||
|
// any spline EG is active. resolvePlay enforces it on the way to the engine; the editor's
|
||||||
|
// play-mode toggle refuses the Gate segment so the two agree.
|
||||||
|
template <class Play>
|
||||||
|
bool splineActive(const Play& p) {
|
||||||
|
return p.ampSpline.mode == EnvMode::Spline || p.pitchSpline.mode == EnvMode::Spline ||
|
||||||
|
p.filterSpline.mode == EnvMode::Spline;
|
||||||
|
}
|
||||||
|
|
||||||
// [start, end) frames, half-open. A zero-length loop (start == end) is the "no sustain loop"
|
// [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.
|
// marker — a held note past the sample end goes silent rather than looping a zero span.
|
||||||
struct SampleLoop {
|
struct SampleLoop {
|
||||||
|
|||||||
@@ -62,9 +62,18 @@ VelocityCurve VelocityCurve::zero() {
|
|||||||
return c;
|
return c;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
VelocityCurve VelocityCurve::rampDown() {
|
||||||
|
VelocityCurve c;
|
||||||
|
c.points_ = {{kVelMin, kCurveYMax, false}, {kVelMax, 0.0, false}};
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
VelocityCurve VelocityCurve::fromPoints(std::vector<VelocityPoint> pts, CurveDomain domain) {
|
VelocityCurve VelocityCurve::fromPoints(std::vector<VelocityPoint> pts, CurveDomain domain) {
|
||||||
// Stable sort so coincident-X points keep their wire order (eval stays well-defined for
|
// Stable sort so coincident-X points keep their wire order (eval stays well-defined for
|
||||||
// duplicate-X knots).
|
// 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) {
|
for (VelocityPoint& p : pts) {
|
||||||
p.velocity = clampVelocity(p.velocity);
|
p.velocity = clampVelocity(p.velocity);
|
||||||
p.value = clampValue(p.value, domain);
|
p.value = clampValue(p.value, domain);
|
||||||
@@ -77,42 +86,34 @@ VelocityCurve VelocityCurve::fromPoints(std::vector<VelocityPoint> pts, CurveDom
|
|||||||
return domain == CurveDomain::Bipolar ? zero() : flat();
|
return domain == CurveDomain::Bipolar ? zero() : flat();
|
||||||
}
|
}
|
||||||
if (pts.front().velocity > kVelMin) {
|
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 {
|
} else {
|
||||||
pts.front().velocity = kVelMin;
|
pts.front().velocity = kVelMin;
|
||||||
}
|
}
|
||||||
if (pts.back().velocity < kVelMax) {
|
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 {
|
} else {
|
||||||
pts.back().velocity = kVelMax;
|
pts.back().velocity = kVelMax;
|
||||||
}
|
}
|
||||||
|
if (pts.size() > kMaxCurvePoints) {
|
||||||
|
// Drop the interior points nearest the end, never an endpoint.
|
||||||
|
pts.erase(pts.begin() + static_cast<std::ptrdiff_t>(kMaxCurvePoints) - 1,
|
||||||
|
pts.end() - 1);
|
||||||
|
}
|
||||||
VelocityCurve c;
|
VelocityCurve c;
|
||||||
c.domain_ = domain;
|
c.domain_ = domain;
|
||||||
c.points_ = std::move(pts);
|
c.points_ = std::move(pts);
|
||||||
return c;
|
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 {
|
double VelocityCurve::eval(double velocity) const {
|
||||||
if (points_.empty()) return curveNeutral(domain_);
|
if (points_.empty()) return curveNeutral(domain_);
|
||||||
if (points_.size() == 1) return clampValue(points_[0].value, domain_);
|
if (points_.size() == 1) return clampValue(points_[0].value, domain_);
|
||||||
const double v = clampVelocity(velocity);
|
const double v = clampVelocity(velocity);
|
||||||
if (v <= points_.front().velocity) return clampValue(points_.front().value, domain_);
|
if (v <= points_.front().velocity) return clampValue(points_.front().value, domain_);
|
||||||
if (v >= points_.back().velocity) return clampValue(points_.back().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) {
|
for (std::size_t i = 0; i + 1 < points_.size(); ++i) {
|
||||||
const VelocityPoint& a = points_[i];
|
const VelocityPoint& a = points_[i];
|
||||||
const VelocityPoint& b = points_[i + 1];
|
const VelocityPoint& b = points_[i + 1];
|
||||||
@@ -120,55 +121,38 @@ double VelocityCurve::eval(double velocity) const {
|
|||||||
const double span = b.velocity - a.velocity;
|
const double span = b.velocity - a.velocity;
|
||||||
// Coincident-X neighbours (a step): zero-width segment, no interior to blend.
|
// Coincident-X neighbours (a step): zero-width segment, no interior to blend.
|
||||||
if (span <= 0.0) return clampValue(b.value, domain_);
|
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;
|
const double d = (b.value - a.value) / span;
|
||||||
|
const SegmentTangents m = segmentTangents(points_.data(), points_.size(), i, d, span);
|
||||||
double mA = d;
|
const double y = hermiteAt(a.value, b.value, span, m.mA, m.mB,
|
||||||
if (i > 0) {
|
(v - a.velocity) / span);
|
||||||
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;
|
|
||||||
return clampValue(y, domain_);
|
return clampValue(y, domain_);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return clampValue(points_.back().value, domain_); // unreachable (v is between the endpoints)
|
return clampValue(points_.back().value, domain_); // unreachable (v is between the endpoints)
|
||||||
}
|
}
|
||||||
|
|
||||||
std::size_t VelocityCurve::addPoint(double velocity, double value) {
|
int VelocityCurve::addPoint(double velocity, double value) {
|
||||||
const VelocityPoint p{clampVelocity(velocity), clampValue(value, domain_)};
|
// 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.
|
// First index strictly greater, so a duplicate-X point lands immediately after the existing one.
|
||||||
std::size_t i = 0;
|
std::size_t i = 0;
|
||||||
while (i < points_.size() && points_[i].velocity <= p.velocity) ++i;
|
while (i < points_.size() && points_[i].velocity <= p.velocity) ++i;
|
||||||
points_.insert(points_.begin() + static_cast<std::ptrdiff_t>(i), p);
|
points_.insert(points_.begin() + static_cast<std::ptrdiff_t>(i), p);
|
||||||
return i;
|
return static_cast<int>(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool VelocityCurve::toggleHard(std::size_t index) {
|
||||||
|
if (index >= points_.size()) return false;
|
||||||
|
points_[index].hard = !points_[index].hard;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool VelocityCurve::setHard(std::size_t index, bool hard) {
|
||||||
|
if (index >= points_.size()) return false;
|
||||||
|
points_[index].hard = hard;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
VelocityPoint VelocityCurve::movePoint(std::size_t index, double velocity, double value) {
|
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;
|
const double hi = points_[index + 1].velocity;
|
||||||
newVel = std::clamp(clampVelocity(velocity), lo, hi);
|
newVel = std::clamp(clampVelocity(velocity), lo, hi);
|
||||||
}
|
}
|
||||||
points_[index] = VelocityPoint{newVel, newValue};
|
points_[index] = VelocityPoint{newVel, newValue, points_[index].hard};
|
||||||
return points_[index];
|
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) {
|
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].velocity - other.points_[i].velocity) > eps) return false;
|
||||||
if (std::fabs(points_[i].value - other.points_[i].value) > 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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,32 @@
|
|||||||
// velocity_curve.h — the velocity->modulation transfer curve, shared by all three
|
// velocity_curve.h — THE monotone spline, shared by every consumer: the three velocity
|
||||||
// destinations (amp gain, pitch offset, filter cutoff offset). eval(velocity) is called once
|
// transfer curves (amp gain, pitch offset, filter cutoff offset), evaluated once per note-on,
|
||||||
// per note-on in Voice::start(), never per frame. Editor hit-test/inverse-map take an explicit
|
// and the spline EGs, evaluated per voice per sample through SplineCursor. Editor
|
||||||
// pixel Box rather than a Rect: this module sits below sampler_core in the link graph and must
|
// hit-test/inverse-map take an explicit pixel Box rather than a Rect: this module sits below
|
||||||
// not gain a transitive dependency on editor-layout types.
|
// sampler_core in the link graph and must not gain a dependency on editor-layout types.
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
namespace reasampler::instrument::engine {
|
namespace reasampler::instrument::engine {
|
||||||
|
|
||||||
// The MIDI velocity domain [0,127] — the X span every point clamps into.
|
// The curve's canonical X span. For the three velocity consumers it IS the MIDI velocity
|
||||||
inline constexpr double kVelMin = 0.0;
|
// domain; a spline EG maps normalized sample time onto the same span, which is what lets one
|
||||||
inline constexpr double kVelMax = 127.0;
|
// 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;
|
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
|
// 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
|
// 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
|
// 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
|
// 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.
|
// through the named constructors / addPoint rather than pushing raw points.
|
||||||
struct VelocityPoint {
|
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
|
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.
|
// Pick radius (px) around a node's drawn point for the editor hit-test.
|
||||||
inline constexpr int kCurveNodeGrabRadius = 6;
|
inline constexpr int kCurveNodeGrabRadius = 6;
|
||||||
|
|
||||||
// An X-ordered list of control points spanning [0,127], evaluated by a monotone cubic Hermite
|
// An X-ordered list of control points spanning the canonical X span, evaluated as ONE OR MORE
|
||||||
// spline (Fritsch-Carlson slope limiting) — a genuine curve, not a polyline, that provably never
|
// monotone cubic Hermite splines (Fritsch-Carlson slope limiting) joined at the hard points — a
|
||||||
// overshoots a segment's value range. For collinear knots the tangents reduce to the secant
|
// genuine curve, not a polyline, that provably never overshoots any segment's value range. The
|
||||||
// slope, so the spline reproduces linear()'s straight line to within ~1e-15. The two endpoints
|
// guarantee is PER SEGMENT, so a contour is free to rise and fall. For collinear knots the
|
||||||
// (velocity 0 and 127) are load-bearing: they keep eval total over the domain and are never
|
// tangents reduce to the secant slope, so the spline reproduces linear()'s straight line to
|
||||||
// deletable.
|
// within ~1e-15. The two endpoints are load-bearing: they keep eval total over the domain and
|
||||||
|
// are never deletable.
|
||||||
class VelocityCurve {
|
class VelocityCurve {
|
||||||
public:
|
public:
|
||||||
// flat() (endpoints (0,1)/(127,1), every velocity -> unity) is the unipolar default — see
|
// flat() (endpoints (0,1)/(127,1), every velocity -> unity) is the unipolar default — see
|
||||||
@@ -56,6 +123,10 @@ public:
|
|||||||
static VelocityCurve linear();
|
static VelocityCurve linear();
|
||||||
// The bipolar default: flat at 0, so velocity modulates nothing until a curve is drawn.
|
// The bipolar default: flat at 0, so velocity modulates nothing until a curve is drawn.
|
||||||
static VelocityCurve zero();
|
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
|
// Rebuilds from a deserialized point list, repairing the invariant defensively: box-clamps
|
||||||
// each point into `domain`, stable-sorts by velocity, forces both endpoints present
|
// each point into `domain`, stable-sorts by velocity, forces both endpoints present
|
||||||
@@ -73,8 +144,15 @@ public:
|
|||||||
double eval(double velocity) const;
|
double eval(double velocity) const;
|
||||||
|
|
||||||
// Inserted at a velocity duplicating an existing point lands immediately after it, so a
|
// Inserted at a velocity duplicating an existing point lands immediately after it, so a
|
||||||
// subsequent move can separate them. Returns the inserted index.
|
// subsequent move can separate them. Returns the inserted index, or -1 when the curve is
|
||||||
std::size_t addPoint(double velocity, double value);
|
// 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
|
// 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.
|
// 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;
|
CurveDomain domain_ = CurveDomain::Unipolar;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The RT read head over a contour: an indexed segment search plus one Hermite evaluation, with
|
||||||
|
// the segment and its two tangents cached across samples so a monotone read costs one compare.
|
||||||
|
// Header-inline, branch-only, NO allocation and NO virtual dispatch — it runs per voice per
|
||||||
|
// sample. A jump (a loop wrap, a fresh note) falls back to a binary search, <= 7 steps at the
|
||||||
|
// 128-point ceiling.
|
||||||
|
//
|
||||||
|
// Holds a RAW POINTER into the bound curve's point array: the caller guarantees the curve
|
||||||
|
// outlives the cursor. The voice binds against its SampleData, which has exactly that lifetime.
|
||||||
|
class SplineCursor {
|
||||||
|
public:
|
||||||
|
// Binds `c` if it has an evaluable segment; a shorter curve leaves the cursor inactive so
|
||||||
|
// the caller's `if (active())` skips the whole spline path.
|
||||||
|
void bind(const VelocityCurve& c) {
|
||||||
|
const std::vector<VelocityPoint>& pts = c.points();
|
||||||
|
if (pts.size() < 2) { clear(); return; }
|
||||||
|
pts_ = pts.data();
|
||||||
|
n_ = pts.size();
|
||||||
|
select(0);
|
||||||
|
}
|
||||||
|
void clear() { pts_ = nullptr; n_ = 0; }
|
||||||
|
bool active() const { return n_ >= 2; }
|
||||||
|
|
||||||
|
// `phase` is normalized position over the contour's whole span, [0,1]; out-of-range clamps
|
||||||
|
// to the terminal values (a note past its span holds the contour's last level).
|
||||||
|
double eval(double phase) {
|
||||||
|
const double x = (phase <= 0.0) ? kCurveXMin
|
||||||
|
: (phase >= 1.0) ? kCurveXMax
|
||||||
|
: kCurveXMin + phase * (kCurveXMax - kCurveXMin);
|
||||||
|
if (x <= x0_ && seg_ == 0) return y0_;
|
||||||
|
if (x >= x1_ && seg_ + 2 == n_) return y1_;
|
||||||
|
if (x < x0_ || x > x1_) locate(x);
|
||||||
|
if (span_ <= 0.0) return y1_; // coincident-X knots: a step, no interior to blend
|
||||||
|
return hermiteAt(y0_, y1_, span_, mA_, mB_, (x - x0_) / span_);
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
// The common case is the next segment (a monotone read walking forward); anything else is a
|
||||||
|
// binary search over the X-ordered array.
|
||||||
|
void locate(double x) {
|
||||||
|
if (x > x1_ && seg_ + 2 < n_ && x <= pts_[seg_ + 2].velocity) { select(seg_ + 1); return; }
|
||||||
|
std::size_t lo = 0, hi = n_ - 2;
|
||||||
|
while (lo < hi) {
|
||||||
|
const std::size_t mid = lo + (hi - lo + 1) / 2;
|
||||||
|
if (pts_[mid].velocity <= x) lo = mid; else hi = mid - 1;
|
||||||
|
}
|
||||||
|
select(lo);
|
||||||
|
}
|
||||||
|
|
||||||
|
void select(std::size_t i) {
|
||||||
|
seg_ = i;
|
||||||
|
x0_ = pts_[i].velocity;
|
||||||
|
x1_ = pts_[i + 1].velocity;
|
||||||
|
y0_ = pts_[i].value;
|
||||||
|
y1_ = pts_[i + 1].value;
|
||||||
|
span_ = x1_ - x0_;
|
||||||
|
const SegmentTangents t =
|
||||||
|
segmentTangents(pts_, n_, i, span_ > 0.0 ? (y1_ - y0_) / span_ : 0.0, span_);
|
||||||
|
mA_ = t.mA;
|
||||||
|
mB_ = t.mB;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VelocityPoint* pts_ = nullptr;
|
||||||
|
std::size_t n_ = 0;
|
||||||
|
std::size_t seg_ = 0;
|
||||||
|
double x0_ = 0.0, x1_ = 0.0, y0_ = 0.0, y1_ = 0.0, span_ = 0.0, mA_ = 0.0, mB_ = 0.0;
|
||||||
|
};
|
||||||
|
|
||||||
} // namespace reasampler::instrument::engine
|
} // namespace reasampler::instrument::engine
|
||||||
|
|||||||
@@ -72,6 +72,26 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
|
|||||||
loop_ = instrument::engine::loop::resolveLoop(sample.loop, sample.loopCrossfadeFrames,
|
loop_ = instrument::engine::loop::resolveLoop(sample.loop, sample.loopCrossfadeFrames,
|
||||||
frameCount, playMode_ == PlayMode::Gate);
|
frameCount, playMode_ == PlayMode::Gate);
|
||||||
|
|
||||||
|
// Bind whichever EGs are drawn. Rebound on EVERY note-on rather than cached: a reload hands
|
||||||
|
// the engine a fresh SampleData, so a stale pointer into the previous one is the bug this
|
||||||
|
// avoids. A Staged EG clears its cursor, which is what keeps the per-sample path off the
|
||||||
|
// spline branch entirely.
|
||||||
|
splineScale_ = frameCount > 0 ? 1.0 / static_cast<double>(frameCount) : 0.0;
|
||||||
|
if (p.ampSpline.mode == EnvMode::Spline) ampSplineCur_.bind(p.ampSpline.contour);
|
||||||
|
else ampSplineCur_.clear();
|
||||||
|
if (p.pitchEnv.enabled && p.pitchSpline.mode == EnvMode::Spline) {
|
||||||
|
pitchSplineCur_.bind(p.pitchSpline.contour);
|
||||||
|
pitchSplineDepth_ = p.pitchEnv.peakSemitones;
|
||||||
|
} else {
|
||||||
|
pitchSplineCur_.clear();
|
||||||
|
pitchSplineDepth_ = 0.0;
|
||||||
|
}
|
||||||
|
if (p.filter.enabled && p.filterSpline.mode == EnvMode::Spline) {
|
||||||
|
filterSplineCur_.bind(p.filterSpline.contour);
|
||||||
|
} else {
|
||||||
|
filterSplineCur_.clear();
|
||||||
|
}
|
||||||
|
|
||||||
// Amplitude envelope: Gate = AHDSR (all five fields read from play.adsr, resolved to
|
// 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.
|
// 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)
|
const std::int64_t postStart = frameCount - start; // >= 1 (start clamped < frameCount)
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ namespace reasampler {
|
|||||||
|
|
||||||
using audio::AudioSample;
|
using audio::AudioSample;
|
||||||
using instrument::engine::PitchShifter;
|
using instrument::engine::PitchShifter;
|
||||||
|
using instrument::engine::SplineCursor;
|
||||||
using instrument::engine::VelocityCurve;
|
using instrument::engine::VelocityCurve;
|
||||||
using instrument::engine::VelocityPoint;
|
using instrument::engine::VelocityPoint;
|
||||||
using instrument::engine::loop::ResolvedLoop;
|
using instrument::engine::loop::ResolvedLoop;
|
||||||
@@ -164,14 +165,24 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
// This frame's amplitude in [0,1] from the active envelope. Gate: AHDSR ticks once per
|
// The read head as a fraction of the whole sample — the domain every spline EG is a pure
|
||||||
// output frame (envelope time is wall-clock, independent of read rate). Trigger: the AHD
|
// 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
|
// 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
|
// frames regardless of pitch engine. Sets amplitudeDone_ on finish so advanceFrame frees
|
||||||
// the voice.
|
// the voice.
|
||||||
double tickAmplitude() {
|
double tickAmplitude() {
|
||||||
double amp;
|
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();
|
amp = env_.tick();
|
||||||
if (env_.finished()) amplitudeDone_ = true;
|
if (env_.finished()) amplitudeDone_ = true;
|
||||||
} else {
|
} else {
|
||||||
@@ -202,9 +213,11 @@ private:
|
|||||||
// The filter envelope takes the amp's shape under the active mode — AHDSR in Gate,
|
// 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
|
// the source-offset AHD in Trigger. playMode_ is fixed for the note's lifetime, so the
|
||||||
// branch is perfectly predicted.
|
// branch is perfectly predicted.
|
||||||
const double envOut = (playMode_ == PlayMode::Gate)
|
const double envOut = filterSplineCur_.active()
|
||||||
? filterEnv_.tick()
|
? filterSplineCur_.eval(splinePhase())
|
||||||
: filterAhd_.amplitudeAt(sourceOffset());
|
: ((playMode_ == PlayMode::Gate)
|
||||||
|
? filterEnv_.tick()
|
||||||
|
: filterAhd_.amplitudeAt(sourceOffset()));
|
||||||
double cut = static_cast<double>(filterBaseCutoff_) + filterModAmount_ * envOut;
|
double cut = static_cast<double>(filterBaseCutoff_) + filterModAmount_ * envOut;
|
||||||
if (cut < 0.0) cut = 0.0;
|
if (cut < 0.0) cut = 0.0;
|
||||||
if (cut > 1.0) cut = 1.0;
|
if (cut > 1.0) cut = 1.0;
|
||||||
@@ -394,7 +407,9 @@ private:
|
|||||||
seedTerminalDeclick();
|
seedTerminalDeclick();
|
||||||
}
|
}
|
||||||
const double gain = amp * velocityGain_;
|
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
|
// 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.
|
// 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
|
std::int64_t playEnd_ = 0; // Trigger: source-frame end; Gate: unused
|
||||||
bool amplitudeDone_ = false; // set when the active amplitude envelope finished
|
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
|
// 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.
|
// 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.
|
// Shared by the output anchor, the Preserve feed, and the start()-time ring prime.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
// own links are velocity_curve + master_gain (wire value validation), never the engine.
|
// 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
|
// 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.
|
// and every version constant; the payload half is IMPLEMENTED in params_payload.
|
||||||
|
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
@@ -104,6 +104,22 @@ namespace reasampler::instrument::map {
|
|||||||
// which transposes nothing. A DOWNGRADE to a pre-v12 binary re-narrows the domain, so a curve
|
// 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.
|
// 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
|
// 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
|
// 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
|
// 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
|
// 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
|
// 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.
|
// 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;
|
inline constexpr std::uint32_t kParamsFormatMarker = 0xFFFFFF00u;
|
||||||
|
|
||||||
// The first SINGLE-RECORD payload version. Everything below it is a retired zone list and
|
// 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.
|
// pre-v12 curve's y values are already valid bipolar ones.
|
||||||
inline constexpr std::uint32_t kParamsVelocityVersion = 12;
|
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
|
// (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
|
// 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
|
// (frames / projectRate = seconds) — the same rate the build already receives, so the
|
||||||
|
|||||||
@@ -50,6 +50,27 @@ void putCurve(std::vector<std::uint8_t>& out, const VelocityCurve& curve) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A spline EG: 1 byte mode, then the contour as count + (x, y, hard) per point. Distinct from
|
||||||
|
// putCurve because the three velocity-curve blocks are frozen at 16 bytes/point and cannot grow
|
||||||
|
// the hard flag; this block was born with it.
|
||||||
|
void putSplineEnv(std::vector<std::uint8_t>& out, const SplineEnv& s) {
|
||||||
|
out.push_back(s.mode == EnvMode::Spline ? 1 : 0);
|
||||||
|
const std::vector<VelocityPoint>& pts = s.contour.points();
|
||||||
|
putLE(out, static_cast<std::uint32_t>(pts.size()));
|
||||||
|
for (const VelocityPoint& pt : pts) {
|
||||||
|
putLE(out, doubleToBits(pt.velocity));
|
||||||
|
putLE(out, doubleToBits(pt.value));
|
||||||
|
out.push_back(pt.hard ? 1 : 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The hard flags of an already-written velocity curve: count + one byte per point.
|
||||||
|
void putHardFlags(std::vector<std::uint8_t>& out, const VelocityCurve& curve) {
|
||||||
|
const std::vector<VelocityPoint>& pts = curve.points();
|
||||||
|
putLE(out, static_cast<std::uint32_t>(pts.size()));
|
||||||
|
for (const VelocityPoint& pt : pts) out.push_back(pt.hard ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
// A stored AHD's five doubles, in one order shared by every AHD on the wire.
|
// A stored AHD's five doubles, in one order shared by every AHD on the wire.
|
||||||
void putAhd(std::vector<std::uint8_t>& out, const AhdSeconds& a) {
|
void putAhd(std::vector<std::uint8_t>& out, const AhdSeconds& a) {
|
||||||
putLE(out, doubleToBits(a.attackSeconds));
|
putLE(out, doubleToBits(a.attackSeconds));
|
||||||
@@ -114,6 +135,41 @@ void readCurveTail(ByteReader& r, VelocityCurve& curve,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read a spline EG. A truncated read leaves `s` at its Staged/default-contour construction
|
||||||
|
// value, which is what makes a pre-v13 blob play exactly as it did.
|
||||||
|
void readSplineEnv(ByteReader& r, SplineEnv& s) {
|
||||||
|
const bool spline = (r.u8() != 0);
|
||||||
|
const std::uint32_t ptCount = r.u32();
|
||||||
|
std::vector<VelocityPoint> pts;
|
||||||
|
// Bound the reserve to what the blob can hold (17 bytes/point) so a corrupt huge count
|
||||||
|
// can't trigger a giant allocation before the bounded reads fail.
|
||||||
|
const std::size_t remaining = r.bytes.size() > r.pos ? r.bytes.size() - r.pos : 0;
|
||||||
|
pts.reserve(std::min(static_cast<std::size_t>(ptCount), remaining / 17));
|
||||||
|
for (std::uint32_t i = 0; i < ptCount && r.ok; ++i) {
|
||||||
|
const double x = bitsToDouble(r.u64());
|
||||||
|
const double y = bitsToDouble(r.u64());
|
||||||
|
const bool hard = (r.u8() != 0);
|
||||||
|
pts.push_back(VelocityPoint{x, y, hard});
|
||||||
|
}
|
||||||
|
if (!r.ok) return;
|
||||||
|
s.mode = spline ? EnvMode::Spline : EnvMode::Staged;
|
||||||
|
s.contour = VelocityCurve::fromPoints(std::move(pts),
|
||||||
|
reasampler::instrument::engine::CurveDomain::Unipolar);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply a hard-flag tail to an already-read velocity curve. A count that disagrees with the
|
||||||
|
// curve fromPoints actually produced is dropped rather than applied to shifted knots.
|
||||||
|
void readHardFlags(ByteReader& r, VelocityCurve& curve) {
|
||||||
|
const std::uint32_t count = r.u32();
|
||||||
|
const std::size_t remaining = r.bytes.size() > r.pos ? r.bytes.size() - r.pos : 0;
|
||||||
|
if (count > remaining) { r.ok = false; return; }
|
||||||
|
std::vector<std::uint8_t> flags;
|
||||||
|
flags.reserve(count);
|
||||||
|
for (std::uint32_t i = 0; i < count && r.ok; ++i) flags.push_back(r.u8());
|
||||||
|
if (!r.ok || flags.size() != curve.size()) return;
|
||||||
|
for (std::size_t i = 0; i < flags.size(); ++i) curve.setHard(i, flags[i] != 0);
|
||||||
|
}
|
||||||
|
|
||||||
// Read the v9 filter tail into `p`. A blob that stops short leaves the off/neutral default,
|
// 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
|
// 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
|
// bipolar at EVERY version — a pre-v12 blob's y values are already valid bipolar ones, so its
|
||||||
@@ -331,6 +387,14 @@ void putParamsPayload(std::vector<std::uint8_t>& out, const InstrumentParams& p)
|
|||||||
putLE(out, asU64(p.loopCrossfadeFrames));
|
putLE(out, asU64(p.loopCrossfadeFrames));
|
||||||
// v12: the velocity->pitch curve.
|
// v12: the velocity->pitch curve.
|
||||||
putCurve(out, pp.pitchVelocityCurve);
|
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
|
// 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,
|
readCurveTail(r, p.play.pitchVelocityCurve,
|
||||||
reasampler::instrument::engine::CurveDomain::Bipolar);
|
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 —
|
// A truncated record leaves whatever parsed plus construction defaults for the rest —
|
||||||
// the same degrade-don't-throw contract the zone ladder always had.
|
// the same degrade-don't-throw contract the zone ladder always had.
|
||||||
if (!r.ok) return PayloadRead{};
|
if (!r.ok) return PayloadRead{};
|
||||||
|
|||||||
@@ -256,6 +256,16 @@ PlayParams resolvePlay(const PlaySeconds& stored, int sampleRate) {
|
|||||||
out.filter.env.decayCurve = stored.filter.env.decayCurve;
|
out.filter.env.decayCurve = stored.filter.env.decayCurve;
|
||||||
out.filter.env.releaseCurve = stored.filter.env.releaseCurve;
|
out.filter.env.releaseCurve = stored.filter.env.releaseCurve;
|
||||||
out.filter.trigEnv = resolveAhd(stored.filter.trigEnv);
|
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;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -202,6 +202,12 @@ struct PlaySeconds {
|
|||||||
PitchEnvSeconds pitchEnv; // AHD pitch modulation, off by default
|
PitchEnvSeconds pitchEnv; // AHD pitch modulation, off by default
|
||||||
VelocityCurve pitchVelocityCurve = VelocityCurve::zero(); // velocity -> pitch, off by default
|
VelocityCurve pitchVelocityCurve = VelocityCurve::zero(); // velocity -> pitch, off by default
|
||||||
FilterSeconds filter; // per-voice filter, 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
|
// Resolve a stored seconds bundle to the engine's frame-domain PlayParams against a live
|
||||||
|
|||||||
@@ -56,6 +56,13 @@ reasampler_pure_library(deck_groups
|
|||||||
# needs the band allocator deck_groups itself has no reason to depend on.
|
# needs the band allocator deck_groups itself has no reason to depend on.
|
||||||
reasampler_test(deck_groups LINK deck_groups sample_bands)
|
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)
|
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
|
# 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.
|
# proving that takes a curve of each domain mapped through the one curveBox.
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ namespace reasampler::instrument::ui {
|
|||||||
namespace {
|
namespace {
|
||||||
int id(DeckParam p) { return static_cast<int>(p); }
|
int id(DeckParam p) { return static_cast<int>(p); }
|
||||||
double clamp(double v, double lo, double hi) { return v < lo ? lo : (v > hi ? hi : v); }
|
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
|
} // namespace
|
||||||
|
|
||||||
double deckBipolarFromNorm(double norm) { return clamp(norm, 0.0, 1.0) * 2.0 - 1.0; }
|
double deckBipolarFromNorm(double norm) { return clamp(norm, 0.0, 1.0) * 2.0 - 1.0; }
|
||||||
@@ -31,6 +36,10 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
|
|||||||
penv.captionWidth = 58;
|
penv.captionWidth = 58;
|
||||||
penv.captionRadio = {id(DeckParam::kPitchEnvSelect)};
|
penv.captionRadio = {id(DeckParam::kPitchEnvSelect)};
|
||||||
penv.captionToggle = {id(DeckParam::kPitchEnvEnable), 32};
|
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),
|
penv.cellIds = {id(DeckParam::kPitchEnvAttack),
|
||||||
id(DeckParam::kPitchEnvHold),
|
id(DeckParam::kPitchEnvHold),
|
||||||
id(DeckParam::kPitchEnvDecay),
|
id(DeckParam::kPitchEnvDecay),
|
||||||
@@ -58,6 +67,7 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
|
|||||||
fenv.id = kGroupFilterEnv;
|
fenv.id = kGroupFilterEnv;
|
||||||
fenv.captionWidth = 66;
|
fenv.captionWidth = 66;
|
||||||
fenv.captionRadio = {id(DeckParam::kFilterEnvSelect)};
|
fenv.captionRadio = {id(DeckParam::kFilterEnvSelect)};
|
||||||
|
fenv.captionToggle2 = {id(DeckParam::kFilterEnvMode), kEnvModeSegW};
|
||||||
if (trigger) {
|
if (trigger) {
|
||||||
fenv.cellIds = {id(DeckParam::kFilterTrigAttack), id(DeckParam::kFilterTrigHold),
|
fenv.cellIds = {id(DeckParam::kFilterTrigAttack), id(DeckParam::kFilterTrigHold),
|
||||||
id(DeckParam::kFilterTrigDecay), -1, -1};
|
id(DeckParam::kFilterTrigDecay), -1, -1};
|
||||||
@@ -76,6 +86,7 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
|
|||||||
amp.captionWidth = 78;
|
amp.captionWidth = 78;
|
||||||
amp.captionRadio = {id(DeckParam::kAmpEnvSelect)};
|
amp.captionRadio = {id(DeckParam::kAmpEnvSelect)};
|
||||||
amp.captionToggle = {id(DeckParam::kPlayMode), 44};
|
amp.captionToggle = {id(DeckParam::kPlayMode), 44};
|
||||||
|
amp.captionToggle2 = {id(DeckParam::kAmpEnvMode), kEnvModeSegW};
|
||||||
if (trigger) {
|
if (trigger) {
|
||||||
// The play span first, then the AHD that shapes it, time-ordered left-to-right so
|
// 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
|
// 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::kAmpEnvSelect:
|
||||||
case DeckParam::kPitchEnvSelect:
|
case DeckParam::kPitchEnvSelect:
|
||||||
case DeckParam::kFilterEnvSelect:
|
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::kVoiceCount:
|
||||||
case DeckParam::kVoiceMode:
|
case DeckParam::kVoiceMode:
|
||||||
case DeckParam::kMonoTrigger:
|
case DeckParam::kMonoTrigger:
|
||||||
@@ -229,24 +245,65 @@ OverlayEnv nextOverlaySelection(OverlayEnv current, int radioId) {
|
|||||||
return (current == picked) ? OverlayEnv::kNone : picked;
|
return (current == picked) ? OverlayEnv::kNone : picked;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool overlayEnvInert(OverlayEnv env, bool pitchEnvEnabled, bool filterEnabled) {
|
OverlayEnv overlayEnvForModeToggle(int toggleId) {
|
||||||
|
switch (static_cast<DeckParam>(toggleId)) {
|
||||||
|
case DeckParam::kAmpEnvMode: return OverlayEnv::kAmp;
|
||||||
|
case DeckParam::kPitchEnvMode: return OverlayEnv::kPitch;
|
||||||
|
case DeckParam::kFilterEnvMode: return OverlayEnv::kFilter;
|
||||||
|
default: return OverlayEnv::kNone;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool overlayEnvEnabled(OverlayEnv env, const DeckEnableState& state) {
|
||||||
switch (env) {
|
switch (env) {
|
||||||
case OverlayEnv::kPitch: return !pitchEnvEnabled;
|
case OverlayEnv::kPitch: return state.pitchEnvEnabled;
|
||||||
case OverlayEnv::kFilter: return !filterEnabled;
|
case OverlayEnv::kFilter: return state.filterEnabled;
|
||||||
case OverlayEnv::kAmp:
|
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:
|
case OverlayEnv::kNone:
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return false; // unreachable for a valid enumerator; silences a warning.
|
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) {
|
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::kPitchEnvAttack:
|
||||||
case DeckParam::kPitchEnvHold:
|
case DeckParam::kPitchEnvHold:
|
||||||
case DeckParam::kPitchEnvDecay:
|
case DeckParam::kPitchEnvDecay:
|
||||||
|
return !state.pitchEnvEnabled || state.pitchSpline;
|
||||||
case DeckParam::kPitchEnvDepth:
|
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::kFilterMorph:
|
||||||
case DeckParam::kFilterCutoff:
|
case DeckParam::kFilterCutoff:
|
||||||
case DeckParam::kFilterQ:
|
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:
|
// 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.
|
// it goes inert with every other one, so no surface can reach a param the knobs can't.
|
||||||
case DeckParam::kFilterVelCurve:
|
case DeckParam::kFilterVelCurve:
|
||||||
case DeckParam::kFilterEnvAttack:
|
return !state.filterEnabled;
|
||||||
case DeckParam::kFilterEnvHold:
|
|
||||||
case DeckParam::kFilterEnvDecay:
|
|
||||||
case DeckParam::kFilterEnvSustain:
|
|
||||||
case DeckParam::kFilterEnvRelease:
|
|
||||||
case DeckParam::kFilterTrigAttack:
|
|
||||||
case DeckParam::kFilterTrigHold:
|
|
||||||
case DeckParam::kFilterTrigDecay:
|
|
||||||
return !filterEnabled;
|
|
||||||
default:
|
default:
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,11 @@ enum class DeckParam {
|
|||||||
kAmpEnvSelect,
|
kAmpEnvSelect,
|
||||||
kPitchEnvSelect,
|
kPitchEnvSelect,
|
||||||
kFilterEnvSelect,
|
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
|
// Deck-only controls: processor-side per-instance params — routed to the processor
|
||||||
// setters, never through the parameter set.
|
// setters, never through the parameter set.
|
||||||
kVoiceCount, // polyphony bound (1..32) — a stepped knob in the VOICE group
|
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.
|
// to, not an error. A non-radio id leaves the selection alone.
|
||||||
OverlayEnv nextOverlaySelection(OverlayEnv current, int radioId);
|
OverlayEnv nextOverlaySelection(OverlayEnv current, int radioId);
|
||||||
|
|
||||||
// Whether the overlay for `env` is INERT: its deck group's enable toggle is off, so its knobs
|
// The group states the two inert predicates below read. One struct rather than a growing
|
||||||
// are drawn-but-dead and a node drag on the same params must be too — otherwise a drag reaches
|
// parameter list, so adding a gate is a change at the two predicates and nowhere else.
|
||||||
// a param a knob couldn't (envelope_edit.h). Amp has no enable toggle and is never inert.
|
struct DeckEnableState {
|
||||||
bool overlayEnvInert(OverlayEnv env, bool pitchEnvEnabled, bool filterEnabled);
|
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
|
// 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
|
// 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. Every other id is
|
// parameter that just sits in that group) while the filter is disabled, and every STAGED
|
||||||
// always live. Mirrors overlayEnvInert's group-toggle-gates-its-knobs shape for the deck's own
|
// 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).
|
// 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
|
// 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
|
// +1. Exact inverses, and exact at the centre detent (0.5 -> 0 -> 0.5), so a knob parked at
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ int knobRowWidth(const DeckGroupDesc& g) {
|
|||||||
int captionRowWidth(const DeckGroupDesc& g) {
|
int captionRowWidth(const DeckGroupDesc& g) {
|
||||||
int w = g.captionWidth;
|
int w = g.captionWidth;
|
||||||
if (g.captionToggle.id >= 0) w += kDeckToggleGap + 2 * g.captionToggle.segWidth;
|
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;
|
if (g.captionRadio.id >= 0) w += kDeckToggleGap + kDeckRadioSize;
|
||||||
return w;
|
return w;
|
||||||
}
|
}
|
||||||
@@ -49,16 +50,20 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) {
|
|||||||
captionRight = out.captionRadio.box.x - kDeckToggleGap;
|
captionRight = out.captionRadio.box.x - kDeckToggleGap;
|
||||||
out.caption.width = captionRight - out.caption.x;
|
out.caption.width = captionRight - out.caption.x;
|
||||||
}
|
}
|
||||||
if (g.captionToggle.id >= 0) {
|
const int togTop = captionTop + (kDeckCaptionH - kDeckToggleH) / 2;
|
||||||
const int segW = g.captionToggle.segWidth;
|
const auto placeToggle = [&](const DeckToggleDesc& d, DeckToggleLayout& into) {
|
||||||
const int togTop = captionTop + (kDeckCaptionH - kDeckToggleH) / 2;
|
if (d.id < 0) return;
|
||||||
|
const int segW = d.segWidth;
|
||||||
const Rect seg1 = Rect::ltrb(captionRight - segW, togTop, captionRight,
|
const Rect seg1 = Rect::ltrb(captionRight - segW, togTop, captionRight,
|
||||||
togTop + kDeckToggleH);
|
togTop + kDeckToggleH);
|
||||||
const Rect seg0 = Rect::ltrb(seg1.x - segW, togTop, seg1.x, togTop + kDeckToggleH);
|
const Rect seg0 = Rect::ltrb(seg1.x - segW, togTop, seg1.x, togTop + kDeckToggleH);
|
||||||
out.captionToggle = DeckToggleLayout{g.captionToggle.id, seg0, seg1};
|
into = DeckToggleLayout{d.id, seg0, seg1};
|
||||||
// Caption text stops at the toggle: pull the right edge in (XYWH: shrink width).
|
captionRight = seg0.x - kDeckToggleGap;
|
||||||
out.caption.width = (seg0.x - kDeckToggleGap) - out.caption.x;
|
// 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.
|
// Knob row: fixed cells left-to-right, then the optional row toggle.
|
||||||
const int cellTop = captionTop + kDeckCaptionH + kDeckCaptionGap;
|
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)) {
|
if (g.captionRadio.id >= 0 && contains(g.captionRadio.box, x, y)) {
|
||||||
return {DeckHitKind::CaptionRadio, g.captionRadio.id, -1, false};
|
return {DeckHitKind::CaptionRadio, g.captionRadio.id, -1, false};
|
||||||
}
|
}
|
||||||
if (g.captionToggle.id >= 0) {
|
for (const DeckToggleLayout* t : {&g.captionToggle, &g.captionToggle2}) {
|
||||||
if (contains(g.captionToggle.seg0, x, y))
|
if (t->id < 0) continue;
|
||||||
return {DeckHitKind::CaptionToggle, g.captionToggle.id, 0};
|
if (contains(t->seg0, x, y)) return {DeckHitKind::CaptionToggle, t->id, 0};
|
||||||
if (contains(g.captionToggle.seg1, x, y))
|
if (contains(t->seg1, x, y)) return {DeckHitKind::CaptionToggle, t->id, 1};
|
||||||
return {DeckHitKind::CaptionToggle, g.captionToggle.id, 1};
|
|
||||||
}
|
}
|
||||||
if (g.rowToggle.id >= 0) {
|
if (g.rowToggle.id >= 0) {
|
||||||
if (contains(g.rowToggle.seg0, x, y))
|
if (contains(g.rowToggle.seg0, x, y))
|
||||||
|
|||||||
@@ -64,6 +64,11 @@ struct DeckGroupDesc {
|
|||||||
int captionWidth = 60;
|
int captionWidth = 60;
|
||||||
DeckRadioDesc captionRadio; // the caption row's far corner; id -1 = none
|
DeckRadioDesc captionRadio; // the caption row's far corner; id -1 = none
|
||||||
DeckToggleDesc captionToggle; // caption row, left of the radio; id -1 = none
|
DeckToggleDesc captionToggle; // caption row, left of the radio; id -1 = none
|
||||||
|
// A second caption toggle, placed immediately left of the first (or in its place when the
|
||||||
|
// first is absent). Exists because a group whose knob row is wider than its caption row has
|
||||||
|
// caption slack a toggle can occupy for free — a rowToggle would widen the GROUP, and the
|
||||||
|
// deck has six pixels of headroom on its first row at the editor's floor width.
|
||||||
|
DeckToggleDesc captionToggle2;
|
||||||
std::vector<int> cellIds; // knob cells; -1 = blank reserve
|
std::vector<int> cellIds; // knob cells; -1 = blank reserve
|
||||||
DeckToggleDesc rowToggle; // in the knob row after the cells; id -1 = none
|
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)
|
Rect caption; // caption text rect (left part of the caption row)
|
||||||
DeckRadioLayout captionRadio; // id -1 when absent (rect empty)
|
DeckRadioLayout captionRadio; // id -1 when absent (rect empty)
|
||||||
DeckToggleLayout captionToggle; // id -1 when absent (rects empty)
|
DeckToggleLayout captionToggle; // id -1 when absent (rects empty)
|
||||||
|
DeckToggleLayout captionToggle2;
|
||||||
std::vector<DeckCellLayout> cells;
|
std::vector<DeckCellLayout> cells;
|
||||||
DeckToggleLayout rowToggle; // id -1 when absent
|
DeckToggleLayout rowToggle; // id -1 when absent
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// spline_edit.cpp — see spline_edit.h. Pure decision logic; no host types.
|
||||||
|
|
||||||
|
#include "core/instrument/ui/spline_edit.h"
|
||||||
|
|
||||||
|
namespace reasampler::instrument::ui {
|
||||||
|
|
||||||
|
SplineEdit resolveSplineEdit(const VelocityCurve& curve, const VelocityCurve::Box& box,
|
||||||
|
SplineGesture gesture, int x, int y) {
|
||||||
|
if (box.width <= 0 || box.height <= 1) return {};
|
||||||
|
const int idx = curve.pointAtPixel(box, x, y);
|
||||||
|
switch (gesture) {
|
||||||
|
case SplineGesture::kRight:
|
||||||
|
return idx >= 0 ? SplineEdit{SplineEditKind::kDelete, idx} : SplineEdit{};
|
||||||
|
case SplineGesture::kControlLeft:
|
||||||
|
return idx >= 0 ? SplineEdit{SplineEditKind::kToggleHard, idx} : SplineEdit{};
|
||||||
|
case SplineGesture::kLeft:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (idx >= 0) return {SplineEditKind::kGrab, idx};
|
||||||
|
const bool inBox = (x >= box.left && x < box.left + box.width && y >= box.top &&
|
||||||
|
y < box.top + box.height);
|
||||||
|
return inBox ? SplineEdit{SplineEditKind::kAdd, -1} : SplineEdit{};
|
||||||
|
}
|
||||||
|
|
||||||
|
VelocityCurve::Box splineOverlayBox(const OverlayArea& area) {
|
||||||
|
return VelocityCurve::Box{area.rect.x, area.rect.y, area.rect.width, area.rect.height};
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace reasampler::instrument::ui
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// spline_edit.h — THE point-editing grammar, and the one place it is written down. Both spline
|
||||||
|
// consumers route their mouse-down through it — the velocity-curve popup and the spline EG
|
||||||
|
// overlay — so the two cannot drift into two grammars. Mirror of envelope_edit: decision logic
|
||||||
|
// only, no host types, no drawing.
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "core/instrument/engine/velocity_curve.h"
|
||||||
|
#include "core/instrument/ui/editor_geometry.h" // Rect / OverlayArea
|
||||||
|
|
||||||
|
namespace reasampler::instrument::ui {
|
||||||
|
|
||||||
|
using engine::VelocityCurve;
|
||||||
|
|
||||||
|
// The gesture, in the pure module's own vocabulary (the shell maps its modifier state onto it).
|
||||||
|
enum class SplineGesture { kLeft, kRight, kControlLeft };
|
||||||
|
|
||||||
|
// What the gesture resolves to. Left-click adds a point in empty space and grabs an existing
|
||||||
|
// one; right-click deletes; control-click toggles hard/smooth. Points are smooth by default.
|
||||||
|
enum class SplineEditKind { kNone, kGrab, kAdd, kDelete, kToggleHard };
|
||||||
|
|
||||||
|
struct SplineEdit {
|
||||||
|
SplineEditKind kind = SplineEditKind::kNone;
|
||||||
|
int index = -1; // the point the action targets; -1 for kAdd (it has none yet) and kNone
|
||||||
|
};
|
||||||
|
|
||||||
|
// Resolves a click at (x, y) over `box` into an edit. The endpoint and point-count rules are
|
||||||
|
// NOT re-stated here — kDelete on an endpoint and kAdd at the ceiling are refused by
|
||||||
|
// VelocityCurve::deletePoint / addPoint, which the caller applies, so there is exactly one home
|
||||||
|
// for each. A click outside the mapping box resolves to kNone unless it lands on a node's pick
|
||||||
|
// radius: the drawn inset ring must not ADD (the new point would clamp onto an endpoint's x and
|
||||||
|
// stack an undeletable duplicate) but must still be able to grab.
|
||||||
|
SplineEdit resolveSplineEdit(const VelocityCurve& curve, const VelocityCurve::Box& box,
|
||||||
|
SplineGesture gesture, int x, int y);
|
||||||
|
|
||||||
|
// The contour's mapping box inside the waveform overlay: the FULL area, so the drawn contour
|
||||||
|
// spans the whole sample width 1:1 with its time axis. No inset — unlike the popup's box, which
|
||||||
|
// insets to keep endpoint handles clear of the sheet border, this one must stay 1:1 with the
|
||||||
|
// waveform beneath it. Takes the overlay (not a lane) — see waveform_view.h's overlay contract.
|
||||||
|
VelocityCurve::Box splineOverlayBox(const OverlayArea& area);
|
||||||
|
|
||||||
|
} // namespace reasampler::instrument::ui
|
||||||
@@ -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_map`, `component_state_io`, `play_params.h`, `editor_geometry`, `sample_bands`,
|
||||||
`sample_chrome`, `keyboard_strip`, `waveform_view`, `capture_browser`, `browser_scroll`,
|
`sample_chrome`, `keyboard_strip`, `waveform_view`, `capture_browser`, `browser_scroll`,
|
||||||
`param_slider`, `trigger_seam`, `velocity_curve`, `embed_strip`, `knob_deck`,
|
`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.
|
`core/wire` and is documented there — this directory consumes it but does not own it.
|
||||||
|
|
||||||
## Invariants
|
## 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.
|
- `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_<instanceGuid>` 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_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_<instanceGuid>` 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).
|
- `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.
|
- `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.
|
- `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.
|
||||||
|
|||||||
@@ -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.
|
# matching sets, plus the two band-independent surfaces and the platform TU.
|
||||||
editor_session.cpp
|
editor_session.cpp
|
||||||
editor_controls.cpp
|
editor_controls.cpp
|
||||||
|
editor_models.cpp
|
||||||
editor_paint.cpp
|
editor_paint.cpp
|
||||||
editor_paint_chrome.cpp
|
editor_paint_chrome.cpp
|
||||||
editor_paint_waveform.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
|
capture_browser keyboard_strip sample_bands sample_chrome
|
||||||
waveform_view bank_sync browser_scroll param_slider tooltip
|
waveform_view bank_sync browser_scroll param_slider tooltip
|
||||||
theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit
|
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
|
# 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.
|
# 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})
|
target_include_directories(reasampler_vst PRIVATE ${REASAMPLER_SRC_DIR} ${SDK_INC} ${WDL_INC})
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
// editor_controls.cpp — the ReaSamplerEditor's parameter plumbing: the band-stack layout
|
// 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 /
|
// 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
|
// 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
|
// against the pure `deck_groups` module's descriptors, and the node-drag clamp bounds that must
|
||||||
// stored struct each overlay selection maps onto). Value logic only — no painting, no window
|
// match those domains. The orthogonal half — which stored struct each editor selection names —
|
||||||
// plumbing.
|
// is editor_models. Value logic only: no painting, no window plumbing.
|
||||||
|
|
||||||
#include "shell/instrument/reasampler_editor.h"
|
#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); };
|
const auto secToNorm = [](double s) { return clamp01(s / kEnvTimeMaxSeconds); };
|
||||||
switch (static_cast<ParamControl>(id)) {
|
switch (static_cast<ParamControl>(id)) {
|
||||||
case ParamControl::kPlayMode: return play.playMode == PlayMode::Trigger ? 1.0 : 0.0;
|
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::kPitchEngine: return play.pitchEngine == PitchEngine::Preserve ? 1.0 : 0.0;
|
||||||
case ParamControl::kAttack: return secToNorm(play.adsr.attackSeconds);
|
case ParamControl::kAttack: return secToNorm(play.adsr.attackSeconds);
|
||||||
case ParamControl::kHold: return secToNorm(play.adsr.holdSeconds);
|
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; };
|
const auto normToSec = [](double v) { return clamp01(v) * kEnvTimeMaxSeconds; };
|
||||||
switch (static_cast<ParamControl>(id)) {
|
switch (static_cast<ParamControl>(id)) {
|
||||||
case ParamControl::kPlayMode:
|
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;
|
play.playMode = (segment == 1) ? PlayMode::Trigger : PlayMode::Gate;
|
||||||
break;
|
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<ParamControl>(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:
|
case ParamControl::kPitchEngine:
|
||||||
play.pitchEngine = (segment == 1) ? PitchEngine::Preserve : PitchEngine::Varispeed;
|
play.pitchEngine = (segment == 1) ? PitchEngine::Preserve : PitchEngine::Varispeed;
|
||||||
break;
|
break;
|
||||||
@@ -409,9 +433,9 @@ std::string ReaSamplerEditor::deckValueLabel(int id) const {
|
|||||||
}
|
}
|
||||||
|
|
||||||
EnvClampBounds ReaSamplerEditor::envClampBounds() const {
|
EnvClampBounds ReaSamplerEditor::envClampBounds() const {
|
||||||
// Match the deck knobs' own domains so a node drag can never produce a param a knob
|
// Lives here, beside controlValue/applyControl, because it must MATCH them: a node drag can
|
||||||
// couldn't. Every stage time caps at kEnvTimeMaxSeconds; the Hold fractions and the sustain
|
// never produce a param a knob couldn't. Every stage time caps at kEnvTimeMaxSeconds; the
|
||||||
// level are [0,1] by definition and need no bound here.
|
// Hold fractions and the sustain level are [0,1] by definition and need no bound here.
|
||||||
EnvClampBounds b;
|
EnvClampBounds b;
|
||||||
b.maxAttackSeconds = kEnvTimeMaxSeconds;
|
b.maxAttackSeconds = kEnvTimeMaxSeconds;
|
||||||
b.maxHoldSeconds = kEnvTimeMaxSeconds;
|
b.maxHoldSeconds = kEnvTimeMaxSeconds;
|
||||||
@@ -420,141 +444,6 @@ EnvClampBounds ReaSamplerEditor::envClampBounds() const {
|
|||||||
return b;
|
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<double>(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<double>(playLen) / rate : 0.0;
|
|
||||||
const double fullSpan =
|
|
||||||
rate > 0.0 ? static_cast<double>((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<VelocityCurve&>(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) {
|
void ReaSamplerEditor::applyParamControl(int id, double value, int segment) {
|
||||||
if (id == static_cast<int>(ParamControl::kKeyTrack)) {
|
if (id == static_cast<int>(ParamControl::kKeyTrack)) {
|
||||||
// keyTrack sits beside the play bundle (0..200% over kKeyTrackMax); the knob maps 0..1.
|
// keyTrack sits beside the play bundle (0..200% over kKeyTrackMax); the knob maps 0..1.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// editor_input_curve.cpp — the velocity-curve popup's input: the modal click routing,
|
// 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
|
// node grab/add/toggle/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).
|
// delete on EITHER spline surface, and the popup's hover. Band-independent (the sheet floats
|
||||||
|
// over the whole face).
|
||||||
// Windows-only.
|
// Windows-only.
|
||||||
|
|
||||||
#include "shell/instrument/reasampler_editor.h"
|
#include "shell/instrument/reasampler_editor.h"
|
||||||
@@ -8,6 +9,8 @@
|
|||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
|
|
||||||
#include "core/instrument/ui/curve_popup.h" // computeCurvePopup / popupOutsideSheet
|
#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/editor_internal.h" // curveBoxFromRect
|
||||||
#include "shell/instrument/reasampler_processor.h"
|
#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);
|
const VelocityCurve::Box box = curveBoxFromRect(r);
|
||||||
if (box.width <= 0 || box.height <= 1) return;
|
if (box.width <= 0 || box.height <= 1) return;
|
||||||
|
|
||||||
int idx = editedCurve().pointAtPixel(box, x, y);
|
// 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
|
||||||
// Modifier-click (Alt) deletes an interior node — a discrete, final edit committed at once
|
// grammar.
|
||||||
// (deletePoint refuses the two endpoints, so an Alt-click on them is a safe no-op).
|
if ((GetKeyState(VK_MENU) & 0x8000) != 0) {
|
||||||
if (idx >= 0 && (GetKeyState(VK_MENU) & 0x8000) != 0) {
|
const int alt = editedCurve().pointAtPixel(box, x, y);
|
||||||
if (editedCurve().deletePoint(static_cast<std::size_t>(idx))) {
|
if (alt >= 0 && editedCurve().deletePoint(static_cast<std::size_t>(alt))) {
|
||||||
hover_ = HoverTarget{}; // a stale kCurveNode index would light a shifted node
|
hover_ = HoverTarget{}; // a stale kCurveNode index would light a shifted node
|
||||||
commitAndReload();
|
commitAndReload();
|
||||||
}
|
}
|
||||||
return;
|
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<std::size_t>(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
|
// Snapshot BEFORE any mutation so a capture-loss rollback also cancels an in-flight ADD
|
||||||
// (mirror of the other parameter-editing drags' dragStartParams_ contract).
|
// (mirror of the other parameter-editing drags' dragStartParams_ contract).
|
||||||
dragStartParams_ = params_;
|
dragStartParams_ = params_;
|
||||||
|
|
||||||
// Empty-space click inside the mapping box: add a control point via the pure inverse map,
|
int idx = edit.index;
|
||||||
// then grab it. Box-gated (not just contains(r,x,y)) because the inset ring must not add a
|
if (edit.kind == SplineEditKind::kAdd) {
|
||||||
// point — it would clamp to velocity 0/127, stacking an undeletable duplicate on an
|
const VelocityPoint p = editedCurve().pointFromPixel(box, x, y);
|
||||||
// endpoint. A ring click can still grab an existing node (handled above).
|
idx = editedCurve().addPoint(p.velocity, p.value);
|
||||||
if (idx < 0) {
|
if (idx < 0) return; // at the point ceiling: refused, curve untouched
|
||||||
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<int>(editedCurve().addPoint(p.velocity, p.value));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (idx < 0) return; // ring click with no node hit — nothing to grab
|
|
||||||
|
|
||||||
drag_ = DragKind::kCurveNode;
|
drag_ = DragKind::kCurveNode;
|
||||||
curvePointIndex_ = idx;
|
curvePointIndex_ = idx;
|
||||||
dragStartCurve_ = editedCurve(); // AFTER the add — resolvePointDrag's delta base
|
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) {
|
void ReaSamplerEditor::onMouseRDown(int x, int y) {
|
||||||
// Right-click on a popup curve node deletes it — the primary delete affordance; Alt-click
|
// Right-click deletes a node on EITHER spline surface — the primary delete affordance;
|
||||||
// and drag-off remain as landed alternates. Commits immediately through the same path as
|
// Alt-click and drag-off remain as landed alternates in the popup. deletePoint's endpoint
|
||||||
// Alt-click; deletePoint's endpoint guard makes an endpoint right-click a safe no-op.
|
// guard makes an endpoint right-click a safe no-op. Never acts during an in-flight left
|
||||||
// Right-clicks act only while the popup is open, and never during an in-flight left drag.
|
// drag; the modal popup wins when it is open.
|
||||||
if (!processor_ || view_ == View::kBrowse || curvePopup_ == CurveTarget::kNone) return;
|
if (!processor_ || view_ == View::kBrowse) return;
|
||||||
if (drag_ != DragKind::kNone) return;
|
if (drag_ != DragKind::kNone) return;
|
||||||
RECT rc{};
|
RECT rc{};
|
||||||
GetClientRect(childHwnd_, &rc);
|
GetClientRect(childHwnd_, &rc);
|
||||||
const CurvePopupLayout pl = computeCurvePopup(rc.right - rc.left, rc.bottom - rc.top);
|
if (curvePopup_ != CurveTarget::kNone) {
|
||||||
if (!contains(pl.curveBox, x, y)) return;
|
const CurvePopupLayout pl = computeCurvePopup(rc.right - rc.left, rc.bottom - rc.top);
|
||||||
const VelocityCurve::Box box = curveBoxFromRect(pl.curveBox);
|
if (!contains(pl.curveBox, x, y)) return;
|
||||||
const int idx = editedCurve().pointAtPixel(box, x, y);
|
const SplineEdit edit = resolveSplineEdit(editedCurve(), curveBoxFromRect(pl.curveBox),
|
||||||
if (idx < 0) return;
|
SplineGesture::kRight, x, y);
|
||||||
if (editedCurve().deletePoint(static_cast<std::size_t>(idx))) {
|
if (edit.kind != SplineEditKind::kDelete) return;
|
||||||
hover_ = HoverTarget{}; // a stale kCurveNode index would light a shifted node
|
if (editedCurve().deletePoint(static_cast<std::size_t>(edit.index))) {
|
||||||
commitAndReload();
|
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,
|
ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverCurvePopup(int w, int h, int x,
|
||||||
|
|||||||
@@ -19,8 +19,7 @@ using namespace reasampler::instrument::ui;
|
|||||||
bool ReaSamplerEditor::deckKnobDisabled(int id) const {
|
bool ReaSamplerEditor::deckKnobDisabled(int id) const {
|
||||||
// Thin int-id wrapper over the pure, CTest-covered predicate — see deckKnobInert
|
// Thin int-id wrapper over the pure, CTest-covered predicate — see deckKnobInert
|
||||||
// (deck_groups.h) for which cells go inert and why.
|
// (deck_groups.h) for which cells go inert and why.
|
||||||
return deckKnobInert(static_cast<DeckParam>(id), params_.play.pitchEnv.enabled,
|
return deckKnobInert(static_cast<DeckParam>(id), deckEnableState());
|
||||||
params_.play.filter.enabled);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ReaSamplerEditor::mouseDownDeck(const FaceLayout& fl, int x, int y) {
|
bool ReaSamplerEditor::mouseDownDeck(const FaceLayout& fl, int x, int y) {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
#include "core/instrument/engine/loop/loop_span.h" // maxCrossfade (the shared drag-clamp bound)
|
#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/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 "core/instrument/ui/waveform_view.h" // waveformOverlayArea / markerAtPoint / snap
|
||||||
#include "shell/instrument/editor_internal.h"
|
#include "shell/instrument/editor_internal.h"
|
||||||
#include "shell/instrument/reasampler_processor.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;
|
if (frames <= 0) return false;
|
||||||
const OverlayArea overlay = waveformOverlayArea(fl.bands.waveform);
|
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 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
|
// 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.
|
// same params' knobs inert — there are no grabbable nodes and the markers take every grab.
|
||||||
const double rate = liveSampleRate();
|
const double rate = liveSampleRate();
|
||||||
const bool nodesLive =
|
const bool nodesLive =
|
||||||
overlayEnv_ != OverlayEnv::kNone &&
|
overlayEnv_ != OverlayEnv::kNone && !overlayEnvInert(overlayEnv_, gates);
|
||||||
!overlayEnvInert(overlayEnv_, params_.play.pitchEnv.enabled, params_.play.filter.enabled);
|
|
||||||
if (rate > 0.0 && nodesLive) {
|
if (rate > 0.0 && nodesLive) {
|
||||||
const std::int64_t startFrame = params_.startPoint.value_or(0);
|
const std::int64_t startFrame = params_.startPoint.value_or(0);
|
||||||
const StageEnvelope env = packEnvelope(overlayEnv_, params_.play, frames, startFrame);
|
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<WaveMarker>(hit), m, frames, x);
|
beginMarkerDrag(static_cast<WaveMarker>(hit), m, frames, x);
|
||||||
return true;
|
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;
|
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<std::size_t>(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<std::size_t>(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,
|
void ReaSamplerEditor::beginMarkerDrag(WaveMarker which, const SetupMarkers& m,
|
||||||
std::int64_t frames, int x) {
|
std::int64_t frames, int x) {
|
||||||
drag_ = DragKind::kWaveMarker;
|
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 OverlayArea overlay = waveformOverlayArea(fl.bands.waveform);
|
||||||
const int dx = x - dragStartX_;
|
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<std::size_t>(curvePointIndex_),
|
||||||
|
splineOverlayBox(overlay), dx, y - dragStartY_);
|
||||||
|
invalidate(); // live feedback; commit on release
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (drag_ == DragKind::kEnvNode) {
|
if (drag_ == DragKind::kEnvNode) {
|
||||||
// Resolve the grabbed envelope node's new params from the pixel delta (through the
|
// 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
|
// pure envelope_edit inverse map, clamped), then unpack them back onto the parameter
|
||||||
|
|||||||
@@ -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 <cstdint>
|
||||||
|
#include <utility> // 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<double>(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<double>(playLen) / rate : 0.0;
|
||||||
|
const double fullSpan =
|
||||||
|
rate > 0.0 ? static_cast<double>((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<VelocityCurve&>(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<VelocityCurve&>(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
|
||||||
@@ -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
|
// 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.
|
// 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,
|
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 hov = !disabled && isHovered(HoverKind::kControl, t.id);
|
||||||
|
const bool d0 = disabled || seg0Disabled;
|
||||||
const InteractionState st0 =
|
const InteractionState st0 =
|
||||||
disabled ? InteractionState::Disabled
|
d0 ? InteractionState::Disabled
|
||||||
: (!seg1Active ? InteractionState::Active
|
: (!seg1Active ? InteractionState::Active
|
||||||
: (hov ? InteractionState::Hover : InteractionState::Rest));
|
: (hov ? InteractionState::Hover : InteractionState::Rest));
|
||||||
const InteractionState st1 =
|
const InteractionState st1 =
|
||||||
disabled ? InteractionState::Disabled
|
disabled ? InteractionState::Disabled
|
||||||
: (seg1Active ? InteractionState::Active
|
: (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.seg0), Role::BgCell, st0);
|
||||||
fillSurface(bmp, toKitBox(t.seg1), Role::BgCell, st1);
|
fillSurface(bmp, toKitBox(t.seg1), Role::BgCell, st1);
|
||||||
kitTextCentered(bmp, t.seg0, s0, Font::Micro,
|
kitTextCentered(bmp, t.seg0, s0, Font::Micro,
|
||||||
disabled ? Role::TextDim
|
d0 ? Role::TextDim
|
||||||
: (!seg1Active ? Role::BgBase : Role::TextPrimary));
|
: (!seg1Active ? Role::BgBase : Role::TextPrimary));
|
||||||
kitTextCentered(bmp, t.seg1, s1, Font::Micro,
|
kitTextCentered(bmp, t.seg1, s1, Font::Micro,
|
||||||
disabled ? Role::TextDim
|
disabled ? Role::TextDim
|
||||||
: (seg1Active ? Role::BgBase : Role::TextPrimary));
|
: (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
|
// The knob's short name label (swapped for the live value during hover/drag — no third
|
||||||
// line, no permanent value clutter).
|
// 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).
|
// The compact caption toggles (right-anchored in the caption row, never full-width).
|
||||||
if (g.captionToggle.id >= 0) {
|
for (const DeckToggleLayout* tp : {&g.captionToggle, &g.captionToggle2}) {
|
||||||
switch (static_cast<ParamControl>(g.captionToggle.id)) {
|
if (tp->id < 0) continue;
|
||||||
|
const DeckToggleLayout& t = *tp;
|
||||||
|
switch (static_cast<ParamControl>(t.id)) {
|
||||||
case ParamControl::kPlayMode:
|
case ParamControl::kPlayMode:
|
||||||
drawToggle(g.captionToggle, "Gate", "Trigger",
|
drawToggle(t, "Gate", "Trigger", play.playMode == PlayMode::Trigger, false,
|
||||||
play.playMode == PlayMode::Trigger, false);
|
/*seg0Disabled=*/anySpline);
|
||||||
break;
|
break;
|
||||||
case ParamControl::kPitchEngine:
|
case ParamControl::kPitchEngine:
|
||||||
drawToggle(g.captionToggle, "Varisp", "Presrv",
|
drawToggle(t, "Varisp", "Presrv",
|
||||||
play.pitchEngine == PitchEngine::Preserve, false);
|
play.pitchEngine == PitchEngine::Preserve, false);
|
||||||
break;
|
break;
|
||||||
case ParamControl::kPitchEnvEnable:
|
case ParamControl::kPitchEnvEnable:
|
||||||
drawToggle(g.captionToggle, "Off", "On", play.pitchEnv.enabled, false);
|
drawToggle(t, "Off", "On", play.pitchEnv.enabled, false);
|
||||||
break;
|
break;
|
||||||
case ParamControl::kVoiceMode:
|
case ParamControl::kVoiceMode:
|
||||||
drawToggle(g.captionToggle, "Poly", "Mono", isMono, false);
|
drawToggle(t, "Poly", "Mono", isMono, false);
|
||||||
break;
|
break;
|
||||||
case ParamControl::kFilterEnable:
|
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;
|
break;
|
||||||
default: break;
|
default: break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include "core/audio/peaks.h" // computeEnvelope (waveform binning)
|
#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 "core/instrument/ui/waveform_view.h" // waveformSurface / laneEnvelope / frameToX
|
||||||
#include "shell/instrument/editor_internal.h" // kit adapters
|
#include "shell/instrument/editor_internal.h" // kit adapters
|
||||||
#include "shell/instrument/reasampler_processor.h"
|
#include "shell/instrument/reasampler_processor.h"
|
||||||
@@ -131,11 +132,59 @@ void ReaSamplerEditor::paintWaveform(LICE_IBitmap* bmp, const Rect& band) {
|
|||||||
paintEnvelopeOverlay(bmp, overlay, frames);
|
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<instrument::engine::VelocityPoint>& 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<int>(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,
|
void ReaSamplerEditor::paintEnvelopeOverlay(LICE_IBitmap* bmp, const OverlayArea& waveArea,
|
||||||
std::int64_t frames) {
|
std::int64_t frames) {
|
||||||
if (overlayEnv_ == OverlayEnv::kNone) return; // no envelope selected is a resting state
|
if (overlayEnv_ == OverlayEnv::kNone) return; // no envelope selected is a resting state
|
||||||
const Rect& area = waveArea.rect;
|
const Rect& area = waveArea.rect;
|
||||||
if (frames <= 0 || area.width <= 0 || area.height <= 0) return;
|
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();
|
const double rate = liveSampleRate();
|
||||||
if (rate <= 0.0) return;
|
if (rate <= 0.0) return;
|
||||||
const double totalSeconds = static_cast<double>(frames) / rate;
|
const double totalSeconds = static_cast<double>(frames) / rate;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
#include "core/instrument/ui/envelope_overlay.h" // StageEnvelope / EnvNode (envelope overlay draw seam)
|
#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/knob_deck.h" // DeckGroupDesc / DeckLayout (the deck band)
|
||||||
#include "core/instrument/ui/sample_bands.h" // SampleBands (the band-stack allocator)
|
#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/instrument/ui/sample_chrome.h" // ChromeRects (chrome-band interior)
|
||||||
#include "core/audio/peaks.h" // Envelope (the cached peak thumbnail)
|
#include "core/audio/peaks.h" // Envelope (the cached peak thumbnail)
|
||||||
#include "core/instrument/map/sample_map.h" // SampleChoice, BankChoice, InstrumentParams
|
#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
|
// What a mouse drag is currently editing. kWaveMarker/kEnvNode/kCurveNode track their
|
||||||
// grabbed item in waveMarker_/envNode_/curvePointIndex_; kDeckKnob is a grab-anchored
|
// grabbed item in waveMarker_/envNode_/curvePointIndex_; kDeckKnob is a grab-anchored
|
||||||
// knob drag (control in dragParamId_, grab value in dragKnobStartValue_).
|
// 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,
|
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
|
// 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
|
// 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
|
// 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.
|
// only host is the popup sheet. `r` empty -> draws nothing.
|
||||||
void paintVelocityCurve(LICE_IBitmap* bmp, const Rect& r);
|
void paintVelocityCurve(LICE_IBitmap* bmp, const Rect& r);
|
||||||
// Traces the amp-envelope overlay + its draggable node handles over `waveArea`, ONCE at
|
// Traces the overlay-active envelope + its draggable handles over `waveArea`, ONCE at
|
||||||
// full band height (never per lane).
|
// 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);
|
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 ---
|
// --- Input: the mouse-down dispatch and its per-band branches ---
|
||||||
void onMouseDown(int x, int y);
|
void onMouseDown(int x, int y);
|
||||||
@@ -194,13 +209,15 @@ private:
|
|||||||
|
|
||||||
void onMouseMove(int x, int y);
|
void onMouseMove(int x, int y);
|
||||||
void onMouseUp(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
|
// Right-click is the primary node-delete affordance on both spline surfaces — the popup
|
||||||
// popup is open (deletePoint's endpoint guard makes an endpoint right-click a no-op).
|
// 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);
|
void onMouseRDown(int x, int y);
|
||||||
|
|
||||||
// Mouse-down inside curve-editor box `r`: a node grab starts a kCurveNode drag;
|
// Mouse-down inside curve-editor box `r`, resolved through the shared point-editing
|
||||||
// Alt-click on an interior node deletes it at once; an empty-space click adds a point
|
// grammar (spline_edit): a node grab starts a kCurveNode drag, an empty-space click adds a
|
||||||
// and grabs it.
|
// 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);
|
void handleCurveMouseDown(const Rect& r, int x, int y);
|
||||||
|
|
||||||
// Left-click while the curve popup is open (modal over the Sample face): Close /
|
// Left-click while the curve popup is open (modal over the Sample face): Close /
|
||||||
@@ -466,6 +483,20 @@ private:
|
|||||||
CurveTarget curvePopup_ = CurveTarget::kNone;
|
CurveTarget curvePopup_ = CurveTarget::kNone;
|
||||||
void closeCurvePopup();
|
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
|
// 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
|
// 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.
|
// route through it, so a fourth curve or a moved field is a one-place edit.
|
||||||
|
|||||||
@@ -452,7 +452,7 @@ static void testGoldenFullBlobFixture() {
|
|||||||
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0x00,0x00,
|
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,
|
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,
|
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,
|
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,
|
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,
|
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,0x00,0x00,0x00, // value 0.0
|
||||||
0x00,0x00,0x00,0x00,0x00,0xc0,0x5f,0x40, // velocity 127.0
|
0x00,0x00,0x00,0x00,0x00,0xc0,0x5f,0x40, // velocity 127.0
|
||||||
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // value 0.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
|
// clang-format on
|
||||||
CHECK(bytes.size() == sizeof(kGolden));
|
CHECK(bytes.size() == sizeof(kGolden));
|
||||||
@@ -562,17 +592,18 @@ static void testEnvelopePrefixBytesFrozen() {
|
|||||||
CHECK(bytes[4] == 0); // ChannelMode::Mono
|
CHECK(bytes[4] == 0); // ChannelMode::Mono
|
||||||
}
|
}
|
||||||
CHECK(kComponentStateVersion == 11);
|
CHECK(kComponentStateVersion == 11);
|
||||||
CHECK(kParamsPayloadVersion == 12);
|
CHECK(kParamsPayloadVersion == 13);
|
||||||
CHECK(kParamsSingleRecordVersion == 8);
|
CHECK(kParamsSingleRecordVersion == 8);
|
||||||
CHECK(kParamsFormatMarker == 0xFFFFFF00u);
|
CHECK(kParamsFormatMarker == 0xFFFFFF00u);
|
||||||
// The filter, staged-curve, loop and velocity tails rode PAYLOAD bumps, not envelope ones
|
// The filter, staged-curve, loop, velocity and spline tails rode PAYLOAD bumps, not
|
||||||
// — the two axes stay independent, so a future envelope field cannot collide with any of
|
// envelope ones — the two axes stay independent, so a future envelope field cannot collide
|
||||||
// them on one number.
|
// with any of them on one number.
|
||||||
CHECK(kParamsFilterVersion > kParamsSingleRecordVersion);
|
CHECK(kParamsFilterVersion > kParamsSingleRecordVersion);
|
||||||
CHECK(kParamsCurveVersion > kParamsFilterVersion);
|
CHECK(kParamsCurveVersion > kParamsFilterVersion);
|
||||||
CHECK(kParamsLoopVersion > kParamsCurveVersion);
|
CHECK(kParamsLoopVersion > kParamsCurveVersion);
|
||||||
CHECK(kParamsVelocityVersion > kParamsLoopVersion);
|
CHECK(kParamsVelocityVersion > kParamsLoopVersion);
|
||||||
CHECK(kParamsPayloadVersion == kParamsVelocityVersion);
|
CHECK(kParamsSplineVersion > kParamsVelocityVersion);
|
||||||
|
CHECK(kParamsPayloadVersion == kParamsSplineVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- The filter tail (payload v9) --------------------------------------------
|
// --- The filter tail (payload v9) --------------------------------------------
|
||||||
@@ -1460,11 +1491,12 @@ static void testSampleRefsTruncatedMidEntry() {
|
|||||||
// the current params payload for DEFAULT params (marker4+version4 + overrides3 + the
|
// 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
|
// 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
|
// 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,
|
// 36-byte v12 pitch curve + the 135-byte v13 dual-state tail, three 39-byte spline EGs and
|
||||||
// loop 1+8+8, channels4, name 4+0). Cutting 508 keeps the first 27 of entry two's 47 —
|
// three 6-byte hard-flag tails) = 623 bytes; entry two is 47 bytes (id 4+3, path 4+7,
|
||||||
// mid loop.start (offset 23..31).
|
// root4, loop 1+8+8, channels4, name 4+0). Cutting 643 keeps the first 27 of entry two's
|
||||||
CHECK(bytes.size() > 508);
|
// 47 — mid loop.start (offset 23..31).
|
||||||
bytes.resize(bytes.size() - 508);
|
CHECK(bytes.size() > 643);
|
||||||
|
bytes.resize(bytes.size() - 643);
|
||||||
const ComponentState back = deserializeComponentState(bytes, 44100.0);
|
const ComponentState back = deserializeComponentState(bytes, 44100.0);
|
||||||
CHECK(back.sampleRefs.size() == 1);
|
CHECK(back.sampleRefs.size() == 1);
|
||||||
CHECK(back.sampleRefs.size() == 1 && back.sampleRefs[0].sampleId == "kick");
|
CHECK(back.sampleRefs.size() == 1 && back.sampleRefs[0].sampleId == "kick");
|
||||||
|
|||||||
+67
-15
@@ -366,6 +366,7 @@ static void testEveryDeckControlIsClassifiedLiveOrReloading() {
|
|||||||
DeckParam::kAmpVelCurve, DeckParam::kPitchVelCurve, DeckParam::kFilterVelCurve,
|
DeckParam::kAmpVelCurve, DeckParam::kPitchVelCurve, DeckParam::kFilterVelCurve,
|
||||||
DeckParam::kKeyTrack, DeckParam::kTrigLength,
|
DeckParam::kKeyTrack, DeckParam::kTrigLength,
|
||||||
DeckParam::kAmpEnvSelect, DeckParam::kPitchEnvSelect, DeckParam::kFilterEnvSelect,
|
DeckParam::kAmpEnvSelect, DeckParam::kPitchEnvSelect, DeckParam::kFilterEnvSelect,
|
||||||
|
DeckParam::kAmpEnvMode, DeckParam::kPitchEnvMode, DeckParam::kFilterEnvMode,
|
||||||
DeckParam::kVoiceCount, DeckParam::kVoiceMode,
|
DeckParam::kVoiceCount, DeckParam::kVoiceMode,
|
||||||
DeckParam::kMonoTrigger, DeckParam::kMasterGain,
|
DeckParam::kMonoTrigger, DeckParam::kMasterGain,
|
||||||
};
|
};
|
||||||
@@ -450,16 +451,36 @@ static void testANonRadioIdLeavesTheOverlaySelectionAlone() {
|
|||||||
CHECK(nextOverlaySelection(OverlayEnv::kAmp, 9999) == OverlayEnv::kAmp);
|
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
|
// 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.
|
// the same params: a node drag must not reach a value the knob refuses.
|
||||||
static void testOverlayIsInertExactlyWhenItsGroupToggleIsOff() {
|
static void testOverlayIsInertExactlyWhenItsGroupToggleIsOff() {
|
||||||
CHECK(overlayEnvInert(OverlayEnv::kPitch, /*pitchEnv=*/false, /*filter=*/true));
|
CHECK(overlayEnvInert(OverlayEnv::kPitch, gates(/*pitchEnv=*/false, /*filter=*/true)));
|
||||||
CHECK(!overlayEnvInert(OverlayEnv::kPitch, true, true));
|
CHECK(!overlayEnvInert(OverlayEnv::kPitch, gates(true, true)));
|
||||||
CHECK(overlayEnvInert(OverlayEnv::kFilter, true, /*filter=*/false));
|
CHECK(overlayEnvInert(OverlayEnv::kFilter, gates(true, /*filter=*/false)));
|
||||||
CHECK(!overlayEnvInert(OverlayEnv::kFilter, true, true));
|
CHECK(!overlayEnvInert(OverlayEnv::kFilter, gates(true, true)));
|
||||||
// Amp has no enable toggle, so it is never inert; kNone draws nothing to grab.
|
// Amp has no enable toggle, so it is never inert; kNone draws nothing to grab.
|
||||||
CHECK(!overlayEnvInert(OverlayEnv::kAmp, false, false));
|
CHECK(!overlayEnvInert(OverlayEnv::kAmp, gates(false, false)));
|
||||||
CHECK(!overlayEnvInert(OverlayEnv::kNone, 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
|
// 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
|
// 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).
|
// checks before ever routing a curve-cell click to the popup).
|
||||||
static void testDeckKnobIsInertExactlyWithItsGroupsEnableToggle() {
|
static void testDeckKnobIsInertExactlyWithItsGroupsEnableToggle() {
|
||||||
CHECK(deckKnobInert(DeckParam::kFilterVelCurve, /*pitchEnv=*/true, /*filter=*/false));
|
CHECK(deckKnobInert(DeckParam::kFilterVelCurve, gates(/*pitchEnv=*/true, /*filter=*/false)));
|
||||||
CHECK(!deckKnobInert(DeckParam::kFilterVelCurve, true, true));
|
CHECK(!deckKnobInert(DeckParam::kFilterVelCurve, gates(true, true)));
|
||||||
CHECK(deckKnobInert(DeckParam::kFilterCutoff, true, false));
|
CHECK(deckKnobInert(DeckParam::kFilterCutoff, gates(true, false)));
|
||||||
CHECK(!deckKnobInert(DeckParam::kFilterCutoff, true, true));
|
CHECK(!deckKnobInert(DeckParam::kFilterCutoff, gates(true, true)));
|
||||||
CHECK(deckKnobInert(DeckParam::kPitchEnvDepth, /*pitchEnv=*/false, true));
|
CHECK(deckKnobInert(DeckParam::kPitchEnvDepth, gates(/*pitchEnv=*/false, true)));
|
||||||
CHECK(!deckKnobInert(DeckParam::kPitchEnvDepth, true, true));
|
CHECK(!deckKnobInert(DeckParam::kPitchEnvDepth, gates(true, true)));
|
||||||
// The amp's own velocity cell and every ordinary control are never inert here — inertness
|
// The amp's own velocity cell and every ordinary control are never inert here — inertness
|
||||||
// is a filter/pitch-env-group-only concept.
|
// is a filter/pitch-env-group-only concept until an envelope is drawn.
|
||||||
CHECK(!deckKnobInert(DeckParam::kAmpVelCurve, false, false));
|
CHECK(!deckKnobInert(DeckParam::kAmpVelCurve, gates(false, false)));
|
||||||
CHECK(!deckKnobInert(DeckParam::kAttack, 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() {
|
int main() {
|
||||||
@@ -485,6 +535,8 @@ int main() {
|
|||||||
testANonRadioIdLeavesTheOverlaySelectionAlone();
|
testANonRadioIdLeavesTheOverlaySelectionAlone();
|
||||||
testOverlayIsInertExactlyWhenItsGroupToggleIsOff();
|
testOverlayIsInertExactlyWhenItsGroupToggleIsOff();
|
||||||
testDeckKnobIsInertExactlyWithItsGroupsEnableToggle();
|
testDeckKnobIsInertExactlyWithItsGroupsEnableToggle();
|
||||||
|
testAModeToggleIsNeitherLiveNorAnOverlayRadio();
|
||||||
|
testTheModeTogglesCostNoGroupWidth();
|
||||||
testEveryDeckControlIsClassifiedLiveOrReloading();
|
testEveryDeckControlIsClassifiedLiveOrReloading();
|
||||||
testOnlyALiveControlsDragTakesTheLiveTier();
|
testOnlyALiveControlsDragTakesTheLiveTier();
|
||||||
testDeckReadsPitchThenFilterThenAmpLeftToRight();
|
testDeckReadsPitchThenFilterThenAmpLeftToRight();
|
||||||
|
|||||||
+12
-12
@@ -26,27 +26,27 @@ static int g_fail = 0;
|
|||||||
// toggle + row toggle), MASTER (1 cell, no toggle).
|
// toggle + row toggle), MASTER (1 cell, no toggle).
|
||||||
static std::vector<DeckGroupDesc> shellLikeDeck() {
|
static std::vector<DeckGroupDesc> shellLikeDeck() {
|
||||||
std::vector<DeckGroupDesc> g;
|
std::vector<DeckGroupDesc> g;
|
||||||
g.push_back({0, 78, {}, {100, 44}, {1, 2, 3, 4, 5}, {}});
|
g.push_back({0, 78, {}, {100, 44}, {}, {1, 2, 3, 4, 5}, {}});
|
||||||
g.push_back({1, 38, {}, {101, 48}, {6}, {}});
|
g.push_back({1, 38, {}, {101, 48}, {}, {6}, {}});
|
||||||
g.push_back({2, 58, {}, {102, 32}, {7, 8, 9}, {}});
|
g.push_back({2, 58, {}, {102, 32}, {}, {7, 8, 9}, {}});
|
||||||
g.push_back({3, 38, {}, {103, 40}, {10}, {104, 44}});
|
g.push_back({3, 38, {}, {103, 40}, {}, {10}, {104, 44}});
|
||||||
g.push_back({4, 46, {}, {}, {11}, {}});
|
g.push_back({4, 46, {}, {}, {}, {11}, {}});
|
||||||
return g;
|
return g;
|
||||||
}
|
}
|
||||||
|
|
||||||
static void testGroupWidth() {
|
static void testGroupWidth() {
|
||||||
// Knob row dominates: 5 cells (240) > caption row (78 + 4 + 88 = 170) -> 240 + 2*6.
|
// 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);
|
CHECK(deckGroupWidth(amp) == 5 * kDeckCellW + 2 * kDeckGroupPadX);
|
||||||
// Caption row dominates: 38 + 4 + 96 = 138 > 48 -> 138 + 12.
|
// 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);
|
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.
|
// 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) ==
|
CHECK(deckGroupWidth(voice) ==
|
||||||
kDeckCellW + kDeckToggleGap + 2 * 44 + 2 * kDeckGroupPadX);
|
kDeckCellW + kDeckToggleGap + 2 * 44 + 2 * kDeckGroupPadX);
|
||||||
// No toggles: max(caption, cells) + padding.
|
// No toggles: max(caption, cells) + padding.
|
||||||
DeckGroupDesc master{4, 46, {}, {}, {11}, {}};
|
DeckGroupDesc master{4, 46, {}, {}, {}, {11}, {}};
|
||||||
CHECK(deckGroupWidth(master) == kDeckCellW + 2 * kDeckGroupPadX);
|
CHECK(deckGroupWidth(master) == kDeckCellW + 2 * kDeckGroupPadX);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,7 +148,7 @@ static void testHitTest() {
|
|||||||
|
|
||||||
// A blank cell (id -1) misses even though its rect exists.
|
// A blank cell (id -1) misses even though its rect exists.
|
||||||
std::vector<DeckGroupDesc> trig;
|
std::vector<DeckGroupDesc> 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 DeckLayout tl = layoutDeck(trig, 0, 0, 824);
|
||||||
const DeckCellLayout& blank = tl.groups[0].cells[4];
|
const DeckCellLayout& blank = tl.groups[0].cells[4];
|
||||||
CHECK(blank.id == -1);
|
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
|
// 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.
|
// toggle left of itself — the three properties the overlay-select switch relies on.
|
||||||
static void testCaptionRadioGeometryAndHit() {
|
static void testCaptionRadioGeometryAndHit() {
|
||||||
const DeckGroupDesc bare{7, 78, {}, {200, 44}, {1, 2}, {}};
|
const DeckGroupDesc bare{7, 78, {}, {200, 44}, {}, {1, 2}, {}};
|
||||||
const DeckGroupDesc withRadio{7, 78, {201}, {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 grows by exactly gap + radio; the knob row is unchanged, so a group whose
|
||||||
// caption row already dominated grows by that much.
|
// caption row already dominated grows by that much.
|
||||||
CHECK(deckGroupWidth(withRadio) - deckGroupWidth(bare) ==
|
CHECK(deckGroupWidth(withRadio) - deckGroupWidth(bare) ==
|
||||||
|
|||||||
@@ -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 <cstdio>
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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 <cmath>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
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<VelocityPoint>& 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<double>(i);
|
||||||
|
CHECK(c.addPoint(x, 0.5) >= 0);
|
||||||
|
}
|
||||||
|
CHECK(c.size() == kMaxCurvePoints);
|
||||||
|
const std::vector<VelocityPoint> before = c.points();
|
||||||
|
CHECK(c.addPoint(63.5, 0.25) == -1);
|
||||||
|
const std::vector<VelocityPoint>& 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<double> 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<std::size_t>(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<std::size_t>(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<std::uint8_t> asV12Payload(std::vector<std::uint8_t> bytes) {
|
||||||
|
int patched = 0;
|
||||||
|
for (std::size_t i = 0; i + 8 <= bytes.size(); ++i) {
|
||||||
|
const std::uint32_t marker = static_cast<std::uint32_t>(bytes[i]) |
|
||||||
|
(static_cast<std::uint32_t>(bytes[i + 1]) << 8) |
|
||||||
|
(static_cast<std::uint32_t>(bytes[i + 2]) << 16) |
|
||||||
|
(static_cast<std::uint32_t>(bytes[i + 3]) << 24);
|
||||||
|
const std::uint32_t ver = static_cast<std::uint32_t>(bytes[i + 4]) |
|
||||||
|
(static_cast<std::uint32_t>(bytes[i + 5]) << 8) |
|
||||||
|
(static_cast<std::uint32_t>(bytes[i + 6]) << 16) |
|
||||||
|
(static_cast<std::uint32_t>(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<double> renderVoice(const SampleData& s, std::size_t frames) {
|
||||||
|
Voice v;
|
||||||
|
v.start(60, 100, s);
|
||||||
|
std::vector<double> 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<double> a = renderVoice(s1, shortLen);
|
||||||
|
const std::vector<double> 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<double>(i) / static_cast<double>(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<double>(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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user